Preview a web application
Last updated Oct 22, 2026
Use a browser to open a web server that runs in a sandbox. The server has no public port, so your Worker forwards requests to it. In this example, the Worker forwards each request under /previews/<NAME>/ to the sandbox named <NAME>.
Prerequisites
- A Worker with a Durable Object that starts a container with the Durable Object scheduling policy. To create one, refer to Run a Linux command.
Forward requests to the server
- Choose the command that starts your server. In this example, the command runs a small Node.js server that returns the request path and the server uptime. Replace it with your own command, such as
npm run dev:
src/index.jsjs
const port = 8080;
const server = `
const { createServer } = require("node:http");
process.on("SIGTERM", () => process.exit(0));
createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({ path: request.url, uptime: Math.round(process.uptime()) }),
);
}).listen(8080, "0.0.0.0");
`;
```
*src/index.tsts*
```ts
const port = 8080;
const server = `
const { createServer } = require("node:http");
process.on("SIGTERM", () => process.exit(0));
createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({ path: request.url, uptime: Math.round(process.uptime()) }),
);
}).listen(8080, "0.0.0.0");
`;
```
The server must listen on `0.0.0.0`, because `getTcpPort()` cannot reach a server that listens only on `127.0.0.1`. Many development servers listen on `localhost` by default and provide an option to change the address. The server runs as the main process. When it exits, for example after a crash, the instance stops, and its files end with it. If commands in the sandbox change files that you need, start the server as described in [Run a server in the background](https://developers.cloudflare.com/sandbox/commands/run-a-server-in-the-background/) instead.
After you deploy, servers built on the Python `http.server` module, such as `python3 -m http.server`, exit on startup, because they look up the container hostname. For a Python command that serves files, refer to [Differences after you deploy](https://developers.cloudflare.com/containers/guides/local-dev/#the-hostname-is-longer-than-a-dns-label).
2. Add an inactivity timeout to your Durable Object. The constructor sets the timeout again when a restarted Durable Object finds the container running:
*src/index.tsts*
```ts
const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
export class MyContainer extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
const container = ctx.container;
if (container?.running) {
void ctx.blockConcurrencyWhile(() =>
container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
);
}
}
}
```
The five-minute inactivity timeout keeps the server running between visits. For more information, refer to [Sandbox lifetime](https://developers.cloudflare.com/sandbox/concepts/lifetime/#the-timeout-does-not-survive-a-restart).
Then start the server and wait until it responds:
*src/index.tsts*
```ts
export class MyContainer extends DurableObject<Env> {
// ...
// Skip the check after the first response
private ready = false;
private async startServer(): Promise<Container> {
const container = this.ctx.container;
if (!container) {
throw new Error("No container is configured");
}
if (!container.running) {
container.start({
image: "cloudflare/debian-trixie",
entrypoint: ["node", "--eval", server],
enableInternet: false,
});
await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
this.ready = false;
}
// `start()` returns before the server listens. Send `GET /` until any
// HTTP response arrives, for up to 30 seconds
const deadline = Date.now() + 30_000;
while (!this.ready && Date.now() < deadline) {
try {
const response = await container
.getTcpPort(port)
.fetch("http://container/", { signal: AbortSignal.timeout(1_000) });
await response.body?.cancel();
this.ready = true;
} catch {
await scheduler.wait(500);
}
}
return container;
}
}
```
3. Forward requests from the `fetch()` handler of your Durable Object:
*src/index.tsts*
```ts
export class MyContainer extends DurableObject<Env> {
// ...
async fetch(request: Request): Promise<Response> {
const container = await this.startServer();
if (!this.ready) {
return new Response("The preview did not start", { status: 503 });
}
// Forward each request once, because a retry could send a `POST` twice
try {
return await container.getTcpPort(port).fetch(request);
} catch {
// Check the server again on the next request
this.ready = false;
return new Response("The preview is not responding", { status: 502 });
}
}
}
```
4. Route preview paths from your Worker to your Durable Object:
*src/index.jsjs*
```js
function forward(sandbox, request, path) {
const url = new URL(request.url);
if (!path) {
url.pathname += "/";
return Response.redirect(url.toString(), 308);
}
// Prefix the host, so that a path such as //example.com/ stays a path.
const target = new URL(`http://container${path}${url.search}`);
return sandbox.fetch(new Request(target, request));
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const preview =
/^\/previews\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/.*)?$/.exec(
url.pathname,
);
if (!preview) {
return new Response("Not found", { status: 404 });
}
const sandbox = env.MY_CONTAINER.getByName(preview[1]);
return forward(sandbox, request, preview[2]);
},
};
```
*src/index.tsts*
```ts
function forward(
sandbox: DurableObjectStub<MyContainer>,
request: Request,
path: string | undefined,
): Promise<Response> | Response {
const url = new URL(request.url);
if (!path) {
url.pathname += "/";
return Response.redirect(url.toString(), 308);
}
// Prefix the host, so that a path such as //example.com/ stays a path.
const target = new URL(`http://container${path}${url.search}`);
return sandbox.fetch(new Request(target, request));
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const preview = /^\/previews\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/.*)?$/.exec(url.pathname);
if (!preview) {
return new Response("Not found", { status: 404 });
}
const sandbox = env.MY_CONTAINER.getByName(preview[1]);
return forward(sandbox, request, preview[2]);
},
} satisfies ExportedHandler<Env>;
```
The server receives each path without the `/previews/<NAME>` prefix. Links that start with `/` leave the preview, so use relative links or [serve each preview on its own hostname](https://developers.cloudflare.com/sandbox/previews/serve-previews-on-their-own-hostnames/).
In this example, the route reads the sandbox name from the URL. The pattern accepts only names that are valid DNS labels: 1 to 63 lowercase letters, digits, and hyphens, with no hyphen at either end. A name that this route accepts can also be a [preview hostname](https://developers.cloudflare.com/sandbox/previews/serve-previews-on-their-own-hostnames/). An encoded `/` does not match, so this route cannot reach a sandbox that another route names with a `/`, such as `<OWNER>/<SESSION>`.
Authenticate callers before they reach a preview. Serve previews from a hostname that does not host your application. For more information, refer to [Sandbox security](https://developers.cloudflare.com/sandbox/concepts/security/#output-carries-the-same-risk).
5. Deploy your Worker:npmyarnpnpm
npx wrangler deploy ```
yarn wrangler deploy
```
pnpm wrangler deploy ```
- Request a page from the preview named
ada. Replace the example hostname with theworkers.devURL that Wrangler prints:
sh
curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/previews/ada/hello
json
{ "path": "/hello", "uptime": 0 }
Wait a few seconds and send the same request again. The uptime increases, because the same server handles both requests:
json
{ "path": "/hello", "uptime": 5 }
Keep WebSocket connections open
The fetch() handler in the previous section passes WebSocket upgrade requests to the server, so WebSocket connections work. A connection that only passes through the Durable Object does not keep the sandbox running. When the inactivity timeout ends, Cloudflare stops the instance and the connection closes.
To keep the sandbox running while a page has a connection open, accept both ends of the connection in the Durable Object:
- Add a function that relays messages between the visitor and the server:
src/index.jsjs
```js function bridge(response) { const upstream = response.webSocket; const [client, server] = Object.values(new WebSocketPair());
// Receive binary messages as ArrayBuffer instead of Blob
upstream.binaryType = "arraybuffer";
server.binaryType = "arraybuffer";
upstream.accept();
server.accept();
// A listener that throws drops the connection, so these never throw. const relay = (from, to) => { from.addEventListener("message", (event) => { try { to.send(event.data); } catch { from.close(1011, "The other side closed"); } }); from.addEventListener("close", (event) => { // 1005 and 1006 report a missing or abnormal close and cannot be sent. const code = event.code === 1005 || event.code === 1006 ? 1000 : event.code; try { to.close(code, event.reason); } catch {} }); };
