---
title: Expose services · Cloudflare Sandbox SDK docs
description: Create preview URLs and expose ports for web services.
lastUpdated: 2025-11-18T19:18:00.000Z
chatbotDeprioritize: false
source_url:
  html: https://developers.cloudflare.com/sandbox/guides/expose-services/
  md: https://developers.cloudflare.com/sandbox/guides/expose-services/index.md
---

Production requires custom domain

Preview URLs require a custom domain with wildcard DNS routing in production. See [Production Deployment](https://developers.cloudflare.com/sandbox/guides/production-deployment/) for setup instructions.

This guide shows you how to expose services running in your sandbox to the internet via preview URLs.

## When to expose ports

Expose ports when you need to:

* **Test web applications** - Preview frontend or backend apps
* **Share demos** - Give others access to running applications
* **Develop APIs** - Test endpoints from external tools
* **Debug services** - Access internal services for troubleshooting
* **Build dev environments** - Create shareable development workspaces

## Basic port exposure

The typical workflow is: start service → wait for ready → expose port → handle requests with `proxyToSandbox`.

* JavaScript

  ```js
  import { getSandbox, proxyToSandbox } from "@cloudflare/sandbox";


  export { Sandbox } from "@cloudflare/sandbox";


  export default {
    async fetch(request, env) {
      // Proxy requests to exposed ports first
      const proxyResponse = await proxyToSandbox(request, env);
      if (proxyResponse) return proxyResponse;


      // Extract hostname from request
      const { hostname } = new URL(request.url);
      const sandbox = getSandbox(env.Sandbox, "my-sandbox");


      // 1. Start a web server
      await sandbox.startProcess("python -m http.server 8000");


      // 2. Wait for service to start
      await new Promise((resolve) => setTimeout(resolve, 2000));


      // 3. Expose the port
      const exposed = await sandbox.exposePort(8000, { hostname });


      // 4. Preview URL is now available (public by default)
      console.log("Server accessible at:", exposed.exposedAt);
      // Production: https://8000-abc123.yourdomain.com
      // Local dev: http://localhost:8787/...


      return Response.json({ url: exposed.exposedAt });
    },
  };
  ```

* TypeScript

  ```ts
  import { getSandbox, proxyToSandbox } from '@cloudflare/sandbox';


  export { Sandbox } from '@cloudflare/sandbox';


  export default {
    async fetch(request: Request, env: Env): Promise<Response> {
      // Proxy requests to exposed ports first
      const proxyResponse = await proxyToSandbox(request, env);
      if (proxyResponse) return proxyResponse;


      // Extract hostname from request
      const { hostname } = new URL(request.url);
      const sandbox = getSandbox(env.Sandbox, 'my-sandbox');


      // 1. Start a web server
      await sandbox.startProcess('python -m http.server 8000');


      // 2. Wait for service to start
      await new Promise(resolve => setTimeout(resolve, 2000));


      // 3. Expose the port
      const exposed = await sandbox.exposePort(8000, { hostname });


      // 4. Preview URL is now available (public by default)
      console.log('Server accessible at:', exposed.exposedAt);
      // Production: https://8000-abc123.yourdomain.com
      // Local dev: http://localhost:8787/...


      return Response.json({ url: exposed.exposedAt });
    }
  };
  ```

Warning

**Preview URLs are public by default.** Anyone with the URL can access your service. Add authentication if needed.

Local development requirement

When using `wrangler dev`, you must add `EXPOSE` directives to your Dockerfile for each port you plan to expose. Without this, you'll see "Connection refused: container port not found". See [Local development](#local-development) section below for setup details.

Uppercase sandbox IDs don't work with preview URLs

Preview URLs extract the sandbox ID from the hostname, which is always lowercase (e.g., `8000-myproject-123.yourdomain.com`). If you created your sandbox with an uppercase ID like `"MyProject-123"`, the URL routes to `"myproject-123"` (a different Durable Object), making your sandbox unreachable.

To fix this, use `normalizeId: true` when creating sandboxes for port exposure:

```ts
const sandbox = getSandbox(env.Sandbox, 'MyProject-123', { normalizeId: true });
```

This lowercases the ID during creation so it matches preview URL routing. Without this, `exposePort()` throws an error.

**Best practice**: Use lowercase IDs from the start (`'my-project-123'`).

See [Sandbox options](https://developers.cloudflare.com/sandbox/configuration/sandbox-options/#normalizeid) for details.

## Name your exposed ports

When exposing multiple ports, use names to stay organized:

* JavaScript

  ```js
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  // Start and expose API server
  await sandbox.startProcess("node api.js", { env: { PORT: "8080" } });
  await new Promise((resolve) => setTimeout(resolve, 2000));
  const api = await sandbox.exposePort(8080, { hostname, name: "api" });


  // Start and expose frontend
  await sandbox.startProcess("npm run dev", { env: { PORT: "5173" } });
  await new Promise((resolve) => setTimeout(resolve, 2000));
  const frontend = await sandbox.exposePort(5173, { hostname, name: "frontend" });


  console.log("Services:");
  console.log("- API:", api.exposedAt);
  console.log("- Frontend:", frontend.exposedAt);
  ```

* TypeScript

  ```ts
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  // Start and expose API server
  await sandbox.startProcess('node api.js', { env: { PORT: '8080' } });
  await new Promise(resolve => setTimeout(resolve, 2000));
  const api = await sandbox.exposePort(8080, { hostname, name: 'api' });


  // Start and expose frontend
  await sandbox.startProcess('npm run dev', { env: { PORT: '5173' } });
  await new Promise(resolve => setTimeout(resolve, 2000));
  const frontend = await sandbox.exposePort(5173, { hostname, name: 'frontend' });


  console.log('Services:');
  console.log('- API:', api.exposedAt);
  console.log('- Frontend:', frontend.exposedAt);
  ```

## Wait for service readiness

Always verify a service is ready before exposing. Use a simple delay for most cases:

* JavaScript

  ```js
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  // Start service
  await sandbox.startProcess("npm run dev", { env: { PORT: "8080" } });


  // Wait 2-3 seconds
  await new Promise((resolve) => setTimeout(resolve, 2000));


  // Now expose
  await sandbox.exposePort(8080, { hostname });
  ```

* TypeScript

  ```ts
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  // Start service
  await sandbox.startProcess('npm run dev', { env: { PORT: '8080' } });


  // Wait 2-3 seconds
  await new Promise(resolve => setTimeout(resolve, 2000));


  // Now expose
  await sandbox.exposePort(8080, { hostname });
  ```

For critical services, poll the health endpoint:

* JavaScript

  ```js
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  await sandbox.startProcess("node api-server.js", { env: { PORT: "8080" } });


  // Wait for health check
  for (let i = 0; i < 10; i++) {
    await new Promise((resolve) => setTimeout(resolve, 1000));


    const check = await sandbox.exec(
      'curl -f http://localhost:8080/health || echo "not ready"',
    );
    if (check.stdout.includes("ok")) {
      break;
    }
  }


  await sandbox.exposePort(8080, { hostname });
  ```

* TypeScript

  ```ts
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  await sandbox.startProcess('node api-server.js', { env: { PORT: '8080' } });


  // Wait for health check
  for (let i = 0; i < 10; i++) {
    await new Promise(resolve => setTimeout(resolve, 1000));


    const check = await sandbox.exec('curl -f http://localhost:8080/health || echo "not ready"');
    if (check.stdout.includes('ok')) {
      break;
    }
  }


  await sandbox.exposePort(8080, { hostname });
  ```

## Multiple services

Expose multiple ports for full-stack applications:

* JavaScript

  ```js
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  // Start backend
  await sandbox.startProcess("node api/server.js", {
    env: { PORT: "8080" },
  });
  await new Promise((resolve) => setTimeout(resolve, 2000));


  // Start frontend
  await sandbox.startProcess("npm run dev", {
    cwd: "/workspace/frontend",
    env: { PORT: "5173", API_URL: "http://localhost:8080" },
  });
  await new Promise((resolve) => setTimeout(resolve, 3000));


  // Expose both
  const api = await sandbox.exposePort(8080, { hostname, name: "api" });
  const frontend = await sandbox.exposePort(5173, { hostname, name: "frontend" });


  return Response.json({
    api: api.exposedAt,
    frontend: frontend.exposedAt,
  });
  ```

* TypeScript

  ```ts
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  // Start backend
  await sandbox.startProcess('node api/server.js', {
    env: { PORT: '8080' }
  });
  await new Promise(resolve => setTimeout(resolve, 2000));


  // Start frontend
  await sandbox.startProcess('npm run dev', {
    cwd: '/workspace/frontend',
    env: { PORT: '5173', API_URL: 'http://localhost:8080' }
  });
  await new Promise(resolve => setTimeout(resolve, 3000));


  // Expose both
  const api = await sandbox.exposePort(8080, { hostname, name: 'api' });
  const frontend = await sandbox.exposePort(5173, { hostname, name: 'frontend' });


  return Response.json({
    api: api.exposedAt,
    frontend: frontend.exposedAt
  });
  ```

## Manage exposed ports

### List currently exposed ports

* JavaScript

  ```js
  const { ports, count } = await sandbox.getExposedPorts();


  console.log(`${count} ports currently exposed:`);


  for (const port of ports) {
    console.log(`  Port ${port.port}: ${port.exposedAt}`);
    if (port.name) {
      console.log(`    Name: ${port.name}`);
    }
  }
  ```

* TypeScript

  ```ts
  const { ports, count } = await sandbox.getExposedPorts();


  console.log(`${count} ports currently exposed:`);


  for (const port of ports) {
    console.log(`  Port ${port.port}: ${port.exposedAt}`);
    if (port.name) {
      console.log(`    Name: ${port.name}`);
    }
  }
  ```

### Unexpose ports

* JavaScript

  ```js
  // Unexpose a single port
  await sandbox.unexposePort(8000);


  // Unexpose multiple ports
  for (const port of [3000, 5173, 8080]) {
    await sandbox.unexposePort(port);
  }
  ```

* TypeScript

  ```ts
  // Unexpose a single port
  await sandbox.unexposePort(8000);


  // Unexpose multiple ports
  for (const port of [3000, 5173, 8080]) {
    await sandbox.unexposePort(port);
  }
  ```

## Best practices

* **Wait for readiness** - Don't expose ports immediately after starting processes
* **Use named ports** - Easier to track when exposing multiple ports
* **Clean up** - Unexpose ports when done to prevent abandoned URLs
* **Add authentication** - Preview URLs are public; protect sensitive services

## Local development

When developing locally with `wrangler dev`, you must expose ports in your Dockerfile:

```dockerfile
FROM docker.io/cloudflare/sandbox:0.3.3


# Expose ports you plan to use
EXPOSE 8000
EXPOSE 8080
EXPOSE 5173
```

Update `wrangler.jsonc` to use your Dockerfile:

```jsonc
{
  "containers": [
    {
      "class_name": "Sandbox",
      "image": "./Dockerfile"
    }
  ]
}
```

In production, all ports are available and controlled programmatically via `exposePort()` / `unexposePort()`.

## Troubleshooting

### Port 3000 is reserved

Port 3000 is used by the internal Bun server and cannot be exposed:

* JavaScript

  ```js
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  // ❌ This will fail
  await sandbox.exposePort(3000, { hostname }); // Error: Port 3000 is reserved


  // ✅ Use a different port
  await sandbox.startProcess("node server.js", { env: { PORT: "8080" } });
  await sandbox.exposePort(8080, { hostname });
  ```

* TypeScript

  ```ts
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  // ❌ This will fail
  await sandbox.exposePort(3000, { hostname });  // Error: Port 3000 is reserved


  // ✅ Use a different port
  await sandbox.startProcess('node server.js', { env: { PORT: '8080' } });
  await sandbox.exposePort(8080, { hostname });
  ```

### Port not ready

Wait for the service to start before exposing:

* JavaScript

  ```js
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  await sandbox.startProcess("npm run dev");
  await new Promise((resolve) => setTimeout(resolve, 3000));
  await sandbox.exposePort(8080, { hostname });
  ```

* TypeScript

  ```ts
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  await sandbox.startProcess('npm run dev');
  await new Promise(resolve => setTimeout(resolve, 3000));
  await sandbox.exposePort(8080, { hostname });
  ```

### Port already exposed

Check before exposing to avoid errors:

* JavaScript

  ```js
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  const { ports } = await sandbox.getExposedPorts();
  if (!ports.some((p) => p.port === 8080)) {
    await sandbox.exposePort(8080, { hostname });
  }
  ```

* TypeScript

  ```ts
  // Extract hostname from request
  const { hostname } = new URL(request.url);


  const { ports } = await sandbox.getExposedPorts();
  if (!ports.some(p => p.port === 8080)) {
    await sandbox.exposePort(8080, { hostname });
  }
  ```

### Uppercase sandbox ID error

**Error**: `Preview URLs require lowercase sandbox IDs`

**Cause**: You created a sandbox with uppercase characters (e.g., `"MyProject-123"`) but preview URLs always use lowercase in routing, causing a mismatch.

**Solution**:

* JavaScript

  ```js
  // Create sandbox with normalization
  const sandbox = getSandbox(env.Sandbox, "MyProject-123", { normalizeId: true });
  await sandbox.exposePort(8080, { hostname });
  ```

* TypeScript

  ```ts
  // Create sandbox with normalization
  const sandbox = getSandbox(env.Sandbox, 'MyProject-123', { normalizeId: true });
  await sandbox.exposePort(8080, { hostname });
  ```

This creates the Durable Object with ID `"myproject-123"`, matching the preview URL routing.

See [Sandbox options - normalizeId](https://developers.cloudflare.com/sandbox/configuration/sandbox-options/#normalizeid) for details.

## Preview URL Format

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

* Port 8080: `https://8080-abc123.yourdomain.com`
* Port 5173: `https://5173-abc123.yourdomain.com`

**Local development**: `http://localhost:8787/...`

**Note**: Port 3000 is reserved for the internal Bun server and cannot be exposed.

## Related resources

* [Ports API reference](https://developers.cloudflare.com/sandbox/api/ports/) - Complete port exposure API
* [Background processes guide](https://developers.cloudflare.com/sandbox/guides/background-processes/) - Managing services
* [Execute commands guide](https://developers.cloudflare.com/sandbox/guides/execute-commands/) - Starting services
