Expose a live shell in a Linux sandbox on a web page. The page runs xterm.js ↗︎ and connects it over a WebSocket to a shell in a tmux ↗︎ session. The shell keeps running when the page reloads or the connection drops.
root@fbed6b5a41ac5bf7fa2b2db27f4d6a8268aab85bdaf6be012a2e881812eb617d:~# stty size 21 95 root@fbed6b5a41ac5bf7fa2b2db27f4d6a8268aab85bdaf6be012a2e881812eb617d:~# for i in $(seq 1 600); do echo "line $i"; sleep 1; done line 1 line 2 line 3 line 4 line 5
- 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.
You must have Docker running locally when you run wrangler deploy. For most people, the best way to install Docker is to follow the docs for installing Docker Desktop ↗︎. Other tools like Colima ↗︎ may also work.
You can check that Docker is running properly by running the docker info command in your terminal. If Docker is running, the command will succeed. If Docker is not running,
the docker info command will hang or return an error including the message "Cannot connect to the Docker daemon".
-
Create a
Dockerfilein the project root with tmux:Dockerfiledockerfile FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends tmux \ && rm -rf /var/lib/apt/lists/* # Scroll back with the mouse wheel, and keep more history. RUN printf 'set -g mouse on\nset -g history-limit 50000\n' > /etc/tmux.conf CMD ["sleep", "infinity"]sleep infinitykeeps the container running so that it can acceptexec()calls for each terminal. -
In
wrangler.jsonc, build theDockerfileas a named image in yourcontainersentry, then generate types for it:{ "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "terminal": { "dockerfile": "./Dockerfile", }, }, }, ], }[[containers]] class_name = "MyContainer" scheduling_policy = "durable_object" [containers.images.terminal] dockerfile = "./Dockerfile"npx wrangler typesyarn wrangler typespnpm wrangler types -
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 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 container keeps running for 5 minutes after the last terminal closes, so a reload finds the same tmux session.
Then add a
fetch()handler that attaches a WebSocket to a tmux session:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async fetch(request: Request): Promise<Response> { const container = this.ctx.container; if (!container) { throw new Error("The container binding is not configured"); } if (request.headers.get("Upgrade") !== "websocket") { return new Response("Expected a WebSocket upgrade", { status: 426 }); } // tmux session names cannot contain "." or ":". const url = new URL(request.url); const session = url.searchParams.get("session") || "main"; if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(session)) { return new Response("Invalid session name", { status: 400 }); } if (!container.running) { container.start({ image: container.images.terminal, // Block commands in the terminal from reaching the Internet enableInternet: false, }); await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } // Attach to the tmux session, or create it. The session keeps the shell // running after this WebSocket closes. const abort = new AbortController(); const tmux = await container.exec( ["tmux", "new-session", "-A", "-s", session], { // Run the tmux client attached to a pseudo-terminal. pty: { cols: Number(url.searchParams.get("cols")) || 80, rows: Number(url.searchParams.get("rows")) || 24, }, env: { TERM: "xterm-256color" }, cwd: "/root", signal: abort.signal, }, ); const [client, server] = Object.values(new WebSocketPair()); // Receive binary messages as ArrayBuffer instead of Blob. server.binaryType = "arraybuffer"; // Keep this Durable Object in memory while the terminal is open. server.accept(); const stdin = tmux.stdin!.getWriter(); // Listeners must not throw. An exception closes the socket without running // the `close` listener, which leaves the tmux client running. server.addEventListener("message", (event) => { if (typeof event.data === "string") { try { const { cols, rows } = JSON.parse(event.data); tmux.resize(cols, rows); } catch { // Ignore malformed resize messages so the terminal keeps running. } } else { stdin.write(new Uint8Array(event.data)).catch(() => {}); } }); let exited = false; const markExited = () => { exited = true; }; tmux.exitCode.then(markExited, markExited); // Stop the tmux client when the browser disconnects. The session and its // shell keep running. server.addEventListener("close", () => { // Signaling a process that has exited raises an error. if (!exited) abort.abort(); }); const forward = async () => { for await (const chunk of tmux.stdout!) { server.send(chunk); } server.close(1000, `Terminal closed with code ${await tmux.exitCode}`); }; void forward().catch(() => server.close(1011, "Terminal output failed")); return new Response(null, { status: 101, webSocket: client }); } }Each WebSocket gets its own tmux client.
tmux new-session -Aattaches the client to the named session, or creates the session if it does not exist. The handler writes keystrokes to the standard input of the client, applies resize messages, and forwards the client output unchanged.The handler accepts the WebSocket with
accept()instead of the Hibernation WebSocket API. Hibernation would discard the process handle of the tmux client.The shell runs as
root. Theuseroption ofexec(), such asuser: "1000:1000", changes the Linux user of the shell. It does not limit what the shell can read or change. For more information, refer to Sandbox security.The
closelistener skips a client that has already exited. For more information, refer to Process lifetime. -
Create a terminal page:
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Sandbox terminal</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css" /> <style> html, body, #terminal { height: 100%; margin: 0; background: #000; } </style> </head> <body> <div id="terminal"></div> <script type="module"> import { Terminal } from "https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/lib/xterm.mjs"; import { FitAddon } from "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/lib/addon-fit.mjs"; // With tmux mouse mode on, hold Shift (Option on macOS) to select text. const terminal = new Terminal({ macOptionClickForcesSelection: true }); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(document.getElementById("terminal")); fitAddon.fit(); const session = new URLSearchParams(location.search).get("session") || "main"; const encoder = new TextEncoder(); let socket; let attempts = 0; const send = (data) => { if (socket.readyState === WebSocket.OPEN) socket.send(data); }; const connect = () => { const url = new URL("terminal", location.href); url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; url.searchParams.set("session", session); url.searchParams.set("cols", terminal.cols); url.searchParams.set("rows", terminal.rows); socket = new WebSocket(url); socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => { attempts = 0; // tmux redraws the whole screen when a client attaches. terminal.reset(); }); socket.addEventListener("message", (event) => { terminal.write(new Uint8Array(event.data)); }); socket.addEventListener("close", (event) => { terminal.writeln(""); // Code 1000 means the session ended or the client detached. if (event.code === 1000 || attempts >= 10) { terminal.writeln("[" + (event.reason || "Disconnected") + "]"); return; } const delay = Math.min(1000 * 2 ** attempts++, 30000); terminal.writeln("[Reconnecting in " + delay / 1000 + " s]"); setTimeout(connect, delay); }); }; connect(); terminal.onData((data) => send(encoder.encode(data))); terminal.onResize(({ cols, rows }) => send(JSON.stringify({ cols, rows }))); addEventListener("resize", () => fitAddon.fit()); </script> </body> </html>The page sends keystrokes as binary messages and window sizes as JSON text messages. When the connection drops, it reconnects after 1 second, doubling the delay up to 30 seconds, and gives up after 10 attempts. It does not reconnect after code
1000, which means the session ended or the client detached.The page loads xterm.js from a CDN. In production, install
@xterm/xtermand@xterm/addon-fitfrom npm and serve them with static assets. -
Add routes to your Worker that serve the page and pass the WebSocket to the sandbox:
src/index.jsjs const page = `[... HTML from step 4 ...]`; export default { async fetch(request, env) { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(terminal)?$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(match[1]); if (match[2]) { return sandbox.fetch(request); } return new Response(page, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); }, };src/index.tsts const page = `[... HTML from step 4 ...]`; export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(terminal)?$/.exec(url.pathname); if (!match) { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(match[1]); if (match[2]) { return sandbox.fetch(request); } return new Response(page, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); }, } satisfies ExportedHandler<Env>;Anyone who opens the page gets a
rootshell in the sandbox named in the URL. Authenticate callers first, and derive the sandbox name from the caller's identity. For more information, refer to Sandbox security. -
Deploy your Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
Open the terminal for the sandbox named
adain your browser. Replace the example hostname with theworkers.devURL that Wrangler prints, and keep the trailing slash:https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/After the sandbox starts, a shell prompt appears with a green tmux status line at the bottom.
-
Run
stty size. The shell prints its rows and columns, such as32 109. Resize the browser window and runstty sizeagain. The numbers change to match the window, minus one row for the status line. -
Start a command that keeps printing, then reload the page:
for i in $(seq 1 600); do echo "line $i"; sleep 1; doneThe terminal attaches to the same shell, and the count has kept going while the page was away. tmux keeps earlier output in its history. Scroll back with the mouse wheel, and press
qto leave the history. -
Open the same URL in a second browser tab. Both tabs show the same screen, and you can type in either. When the tabs are different sizes, tmux fits the screen to the tab used most recently and fills the rest of a larger tab with dots. To open a separate shell in the same sandbox, add
?session=buildto the URL.
Run exit in the shell to end the session. The page shows [Terminal closed with code 0] and does not reconnect. To leave the session running, detach with CTRL + B then D, and reload the page to attach again.
To run an interactive command instead of the default shell, such as the command-line interface of a coding agent, add it after the session name in the exec() call. tmux runs the command when it creates the session:
const tmux = await container.exec(
["tmux", "new-session", "-A", "-s", session, "claude"],
{
// Same options as before.
},
);Install the command in the Dockerfile.
A coding agent also calls its model over the network, which enableInternet: false blocks. To route its requests through your Worker and allow only the hostnames that it needs, refer to interceptOutboundHttps().
To end a session and every process in it without a browser, run tmux kill-session in your Durable Object:
await container.exec(["tmux", "kill-session", "-t", `=${session}`]);The = matches the session name exactly instead of as a prefix. To list sessions, run tmux list-sessions.
- Stream command output: run a command without a terminal and stream its output.
- Terminal workspace example ↗︎: a deployable Worker with this terminal, plus routes that list and end sessions.
- Durable Object container API: every
exec()option. - xterm.js documentation ↗︎: terminal options and add-ons.