Each user gets a registry, a Durable Object that records the sandboxes of that user. In this example, the Worker adds a sandbox to the registry when the sandbox first runs a command. To report which sandboxes are running, the Worker asks the Durable Object of each sandbox whether its container is running.
- 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.
-
In
wrangler.jsonc, add a binding for the registry class, and declare the class inexports:{ "durable_objects": { "bindings": [ { "class_name": "MyContainer", "name": "MY_CONTAINER", }, { "class_name": "SandboxDirectory", "name": "SANDBOX_DIRECTORY", }, ], }, "exports": { "MyContainer": { "type": "durable-object", "storage": "sqlite", }, "SandboxDirectory": { "type": "durable-object", "storage": "sqlite", }, }, }[[durable_objects.bindings]] class_name = "MyContainer" name = "MY_CONTAINER" [[durable_objects.bindings]] class_name = "SandboxDirectory" name = "SANDBOX_DIRECTORY" [exports.MyContainer] type = "durable-object" storage = "sqlite" [exports.SandboxDirectory] type = "durable-object" storage = "sqlite"If your Worker declares its classes with a
migrationsarray instead, add a migration with a newtagand"new_sqlite_classes": ["SandboxDirectory"]. For more information, refer to Durable Object class migrations (legacy).npx wrangler typesyarn wrangler typespnpm wrangler types -
Add the registry class to your Worker. Each user gets one
SandboxDirectory, which stores the user's sandbox sessions in SQLite:src/index.tsts export class SandboxDirectory extends DurableObject<Env> { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); ctx.storage.sql.exec( "CREATE TABLE IF NOT EXISTS sandboxes (session TEXT PRIMARY KEY, created_at INTEGER NOT NULL)", ); } add(session: string) { // Keep the time of the first command when the same session runs again this.ctx.storage.sql.exec( "INSERT OR IGNORE INTO sandboxes (session, created_at) VALUES (?, ?)", session, Date.now(), ); } remove(session: string) { this.ctx.storage.sql.exec("DELETE FROM sandboxes WHERE session = ?", session); } list() { return this.ctx.storage.sql .exec<{ session: string }>("SELECT session FROM sandboxes ORDER BY created_at") .toArray() .map((row) => row.session); } } -
In
MyContainer, set an inactivity timeout, and add methods that report and stop the container. Define the timeout at the top of the file, and add a constructor that sets it when a restarted Durable Object finds the container running:src/index.tsts const INACTIVITY_TIMEOUT_MS = 10 * 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), ); } } }In
exec(), set the timeout afterstart(), inside theif (!container.running)block:src/index.tsts await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);Then add the two methods to
MyContainer:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... isRunning() { // Reading `running` does not start the container return this.ctx.container?.running ?? false; } async destroy() { await this.ctx.container?.destroy(); } }A listing request can wake a restarted Durable Object, which starts without the timeout that
exec()set. The constructor sets the timeout again, so the sandbox does not stop shortly after the listing request ends. For more information, refer to Sandbox lifetime. -
Add routes to your Worker that name each sandbox
<OWNER>/<SESSION>, record the session before running a command, list the sessions with their state, and remove a session after stopping its container:src/index.tsts export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); const [, users, owner, sandboxes, session] = url.pathname.split("/"); if (users !== "users" || !owner || sandboxes !== "sandboxes") { return new Response("Not found", { status: 404 }); } const directory = env.SANDBOX_DIRECTORY.getByName(owner); if (request.method === "GET" && !session) { const sessions = await directory.list(); // Call the Durable Object of every session in parallel const list = await Promise.all( sessions.map(async (name) => ({ session: name, running: await env.MY_CONTAINER.getByName(`${owner}/${name}`).isRunning(), })), ); return Response.json(list); } if (!session) { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(`${owner}/${session}`); if (request.method === "POST") { const { argv } = (await request.json()) as { argv: string[] }; await directory.add(session); return Response.json(await sandbox.exec(argv)); } if (request.method === "DELETE") { await sandbox.destroy(); await directory.remove(session); return new Response(null, { status: 204 }); } return new Response("Not found", { status: 404 }); }, };A sandbox whose container stopped stays in the list with
running: falseuntil the Worker removes its session.Authenticate callers first, and derive the owner from their identity rather than from the URL. Otherwise anyone can list, use, or stop another user's sandboxes. For more information, refer to Sandbox security.
-
Deploy your Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
Start two sandboxes for the user
ada, on theworkers.devURL that Wrangler prints:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/users/ada/sandboxes/notes --request POST --json '{"argv":["uname","-s"]}' curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/users/ada/sandboxes/tests --request POST --json '{"argv":["uname","-s"]}' -
List them:
curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/users/ada/sandboxes[ { "session": "notes", "running": true }, { "session": "tests", "running": true } ] -
Stop one of them, and list again:
curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/users/ada/sandboxes/tests --request DELETE curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/users/ada/sandboxes[{ "session": "notes", "running": true }]
The registry lists only the sandboxes that your Worker recorded. The Containers API lists every sandbox that a Container application started, including sandboxes that failed or that the registry missed. For each sandbox, the list of instances returns the name passed to getByName() and the state:
curl "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/containers/applications/<APPLICATION_ID>/instances?name_prefix=ada/" \
--header "Authorization: Bearer <API_TOKEN>"- The application ID appears in
wrangler containers list. The application is named after the Worker and the Durable Object class, such assandbox-linux-mycontainer. - The API token needs the Containers Read permission, which covers every Container application in the account. Use it for administration and cleanup, not in routes that users call.
name_prefixfilters by the start of the name, andstate=activereturns only sandboxes that are starting, running, or stopping. Stopped sandboxes stay in the list.- A new sandbox can take a few seconds to appear, and sandboxes that run under
wrangler devdo not appear.
- Sandbox lifetime: what keeps a sandbox running, and what stops it.
- Run code when a sandbox stops: record when and why each sandbox stops.
destroy()- SQLite storage API