Sandbox Sandboxes on Cloudflare import { CardGrid, Description, LinkTitleCard, Plan, RelatedProduct, LinkButton, } from "~/components"; Start a sandbox and run untrusted or generated code A sandbox is an isolated place to run code. The code cannot directly read the memory or data of your application. Your Worker decides which application APIs and data the code receives, and whether the code can reach the public Internet. You can use a sandbox for agent-written code, user-uploaded applications, data analysis, development previews, build pipelines, or any other work that should not share a process with your application. Cloudflare provides two sandbox environments. Both are accessible through a Worker, the application that already handles your traffic. If your application uses @cloudflare/sandbox 0.x, refer to Sandbox SDK 0.x, or move it to the current version with Migrate from Sandbox SDK 0.x. Containers Containers run an image you provide. The instance is a full Linux environment, so you can run any language and keep processes running. Your Worker starts the instance and sends it work. HTTP from the Internet reaches the instance only through your Worker. Each instance is a microVM with its own kernel and network, so no other workload shares it. A sandbox container uses the Durable Object scheduling policy, which is in public beta. A Durable Object in your Worker starts the instance from an image and runs a command in it: container.start({ image: "cloudflare/debian-trixie", entrypoint: ["sleep", "infinity"], enableInternet: false, }); const process = await container.exec(["uname", "-a"]); const output = await process.output(); Run a Linux command Build a coding agent runner Dynamic Workers Dynamic Workers create a new Worker at runtime. The untrusted code can be JavaScript, Python, or WebAssembly. Compile TypeScript to JavaScript before loading it. The Workers runtime runs each Dynamic Worker apart from your Worker and from other Dynamic Workers. A Dynamic Worker reaches your application only through the methods and data you pass to it. Your Worker loads the untrusted module as a new Worker, blocks its outbound requests, and calls it: const sandbox = env.LOADER.load({ compatibilityDate: "$today", mainModule: "code.js", modules: { "code.js": untrustedModule }, globalOutbound: null, }); const entrypoint = sandbox.getEntrypoint("Code"); const result = await entrypoint.evaluate(); Run JavaScript Build an AI code interpreter Concepts ", "AI_GATEWAY_ID": "default", "MODEL": "claude-sonnet-5", }, } In src/outbound.ts, make sure the Outbound entrypoint removes the x-api-key header before it adds the gateway token: const headers = new Headers(request.headers); headers.delete("x-api-key"); headers.set("cf-aig-authorization", `Bearer ${this.env.AI_GATEWAY_TOKEN}`); Claude Code sends a placeholder API key. If the x-api-key header stays, AI Gateway forwards it to Anthropic, and the model request fails with Invalid API key. In src/sandbox.ts, replace the ResultEvent constant with a schema for the final event from Claude Code: const ResultEvent = z.object({ type: z.literal("result"), subtype: z.string(), is_error: z.boolean(), result: z.string().optional(), }); Replace the agentCommand() and agentEnv() methods: private agentCommand(prompt: string): string[] { return [ "claude", "--print", "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions", "--no-session-persistence", "--model", this.env.MODEL, "--", prompt, ]; } private agentEnv(): Record { return { ANTHROPIC_BASE_URL: `https://gateway.ai.cloudflare.com/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}/anthropic`, ANTHROPIC_API_KEY: "provided-by-worker", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", IS_SANDBOX: "1", }; } --print runs Claude Code without an interactive terminal. --output-format stream-json --verbose writes one JSON event per line, including a final result event. --dangerously-skip-permissions lets Claude Code run commands and edit files without asking. The container limits what its commands can reach. Claude Code requires IS_SANDBOX=1 to allow this as root. ANTHROPIC_BASE_URL sends model requests to the Anthropic endpoint of your gateway. ANTHROPIC_API_KEY is a placeholder, because Claude Code does not start without an API key. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC turns off update checks, telemetry, and error reporting, so Claude Code calls only your gateway. If the repository has a CLAUDE.md file, Claude Code reads it, so the instructions in that file apply to the task. Replace the outcome() method: export class AgentSandbox extends DurableObject { // ... private async outcome(exitCode: number): Promise { let result: z.infer | undefined; const events = await this.files.readFile(eventsPath); for await (const line of readLines(events.body)) { const parsed = ResultEvent.safeParse(parseJson(line)); if (parsed.success) { result = parsed.data; } } if (result === undefined) { const stderr = (await this.readOptionalText(stderrPath)) ?? ""; return { state: "failed", error: `claude exited with ${exitCode}: ${stderr.slice(-2000)}`, }; } if (result.is_error) { return { state: "failed", error: result.result ?? result.subtype }; } return { state: "succeeded", result: result.result ?? "" }; } } Claude Code exits successfully even when a model request fails, so the outcome comes from the is_error field of the final result event instead of the exit code. Claude Code retries a failing model request 10 times. A rejected gateway token fails the task after about three minutes with Failed to authenticate. API Error: 401 Unauthorized. Deploy your Worker: A running container keeps the image it started with, so use a new sandbox name for Claude Code. Run a task Clone a repository into a sandbox named claude-1: curl "$WORKER_URL/sandboxes/claude-1/repository" \ --json '{"url": "https://github.com/octocat/Hello-World"}' Start a task: curl "$WORKER_URL/sandboxes/claude-1/task" \ --data "Add a file NOTES.md with a one-sentence summary of this repository. Do not commit." Check the task until its state is succeeded or failed: curl "$WORKER_URL/sandboxes/claude-1/task" A succeeded task includes the final reply from Claude Code. Read the changes: curl "$WORKER_URL/sandboxes/claude-1/diff" The diff shows the new NOTES.md file. Related resources Build a coding agent runner: how the runner starts the sandbox, runs the task, and reads the diff. Claude Code example: a separate deployable Worker that runs Claude Code. Claude Code in AI Gateway: route Claude Code through AI Gateway from other environments. Run Claude Managed Agents in a sandbox import { LinkButton } from "~/components"; Claude Managed Agents runs its agent loop on the Anthropic platform. A self-managed environment runs the actions of the agent in your Cloudflare account instead. Cloudflare publishes an open-source template for that environment. Fork it, deploy it to your account, and change it as you need. Get the template :::caution The template is alpha software. It is not yet stable and may contain bugs. ::: How it works When an agent session starts or ends, Anthropic sends a webhook to a Worker that the template deploys in your account. The Worker gives each session its own sandbox, applies an egress policy to its outbound traffic, saves the sandbox state while the session sleeps, and shuts the sandbox down when the session ends. Each agent runs in one of two sandbox types: A Linux sandbox on Containers gives the agent a shell and any process it needs to run. The template builds these sandboxes with Sandbox SDK 0.x. A Dynamic Worker starts in milliseconds and costs less than a container session, but runs code in the Workers runtime without a Linux shell or processes. For more information about the trade-offs, refer to Choose a sandbox environment. What the template adds The template also gives agents access to other Cloudflare products: Agents reach private services over Workers VPC and Cloudflare Mesh without exposing those services to the Internet. Outbound traffic passes through proxies that you configure. A proxy can add credentials to requests without the agent seeing them, restrict the domains an agent can reach, or run your own code on each request. Each session can have its own email address for sending and receiving messages with Email Service. Agents can use headless browsers from Browser Run to fetch pages, take screenshots, and control a browser over the Chrome DevTools Protocol. Agents can generate images with Workers AI. You add a tool by adding a function to one file. Tools run in the Worker with access to all of its bindings. A dashboard lists agents and sessions, shows logs, and opens a shell in a running Linux sandbox. When to use it Use a self-managed Cloudflare environment when you need: Control over the sandbox infrastructure your agents run in Secure connections to private internal services Custom egress policies for credential injection and domain restrictions Custom tools that use Cloudflare bindings, such as R2, D1, KV, and Vectorize A choice between Linux sandboxes and Dynamic Workers for each agent Deploy the template Follow the onboarding guide in the repository. It covers the Anthropic environment and webhook, secrets, D1 migrations, R2 credentials for snapshots, and dashboard security. Deploy with the Deploy to Cloudflare button, which builds the template in Workers Builds, or run npm run deploy from your terminal. :::note You need a Workers Paid plan or an Enterprise account. The template runs Linux sandboxes on Containers, and runs Dynamic Workers and egress proxies through Worker Loader bindings. ::: Template documentation The repository documents each capability: Topic What it covers Connecting to private services Reach services in other clouds, on-premises, or on your laptop with Workers VPC bindings Applying egress policies Allow and deny lists, header injection, custom Worker proxies, and VPC routing Choosing a sandbox type When to run an agent in a Dynamic Worker or a Linux sandbox Agent email Email addresses and sending for agents Browser tools Browser Run tools for agents Adding custom tools Declaring new tools in src/tools/custom-tools.ts Customizing sandboxes The Dockerfile and instance_type of Linux sandboxes Snapshots and state persistence How both sandbox types keep their state while a session sleeps Architecture The request path from webhook to sandbox, and every binding the Worker uses Securing access Securing the dashboard and API Run Codex in a sandbox import { PackageManagers, Steps, WranglerConfig } from "~/components"; Run the Codex CLI in a Linux sandbox to edit a GitHub repository from a prompt, then read its changes as a diff. Codex calls OpenAI models through AI Gateway. Your Worker adds the gateway token to those requests, and the sandbox can reach only your gateway and github.com. Prerequisites The sandbox-coding-agent project from Build a coding agent runner. This page replaces the image, the model, and the parts of the Durable Object that are specific to the agent. OpenAI credentials in AI Gateway, either Unified Billing credits or an OpenAI API key stored as a provider key. Switch the Worker to Codex Replace the Dockerfile. Only the highlighted line differs from the Dockerfile in the tutorial: FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \ && rm -rf /var/lib/apt/lists/* RUN npm install --global --ignore-scripts @openai/codex@0.156.1 COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace CMD ["sleep", "infinity"] The Codex npm package ships its native binaries as dependencies, so it installs without running install scripts. The image pins the version, because the flags and event format of Codex can change between releases. In wrangler.jsonc, set MODEL to an OpenAI model ID that your gateway can serve: { "vars": { "AI_GATEWAY_ACCOUNT_ID": "", "AI_GATEWAY_ID": "default", "MODEL": "gpt-6-sol", }, } Codex sends requests in the OpenAI Responses format, so it works only with OpenAI models that support the Responses API. In src/sandbox.ts, replace the ResultEvent constant with a path for the final message from Codex and a schema for its failure events: const lastMessagePath = `${taskDirectory}/last-message.txt`; const FailureEvent = z.union([ z.object({ type: z.literal("turn.failed"), error: z.object({ message: z.string() }), }), z.object({ type: z.literal("error"), message: z.string() }), ]); Replace the agentCommand() and agentEnv() methods: export class AgentSandbox extends DurableObject { // ... private agentCommand(prompt: string): string[] { const baseUrl = `https://gateway.ai.cloudflare.com/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}/openai`; return [ "codex", // Run one task without an interactive terminal "exec", // Write one JSON event per line to the events file "--json", // Skip the session files that Codex would otherwise save "--ephemeral", "--dangerously-bypass-approvals-and-sandbox", // Write the final message to a file outside the repository "--output-last-message", lastMessagePath, "--model", this.env.MODEL, // Add a custom provider that sends requests to the OpenAI endpoint of your gateway "--config", 'model_provider="cloudflare-ai-gateway"', "--config", `model_providers.cloudflare-ai-gateway={ name = "Cloudflare AI Gateway", base_url = ${JSON.stringify(baseUrl)}, wire_api = "responses" }`, // Stop the analytics and update requests from Codex "--config", "analytics.enabled=false", "--config", "check_for_update_on_startup=false", // Stop Codex from syncing its plugin marketplace, which clones a // repository from `github.com` on every run "--config", "features.plugins=false", "--", prompt, ]; } private agentEnv(): Record { return {}; } } --dangerously-bypass-approvals-and-sandbox turns off the approval prompts and the built-in command sandbox of Codex. The container limits what commands from Codex can reach instead. A custom provider sends no API key, and the Outbound entrypoint adds the gateway token. Codex reads the certificate for intercepted HTTPS from SSL_CERT_FILE, which trustEnv in the runner sets. Replace the outcome() method: export class AgentSandbox extends DurableObject { // ... private async outcome(exitCode: number): Promise { if (exitCode === 0) { return { state: "succeeded", result: (await this.readOptionalText(lastMessagePath)) ?? "", }; } let failure: string | undefined; const events = await this.files.readFile(eventsPath); for await (const line of readLines(events.body)) { const parsed = FailureEvent.safeParse(parseJson(line)); if (parsed.success) { failure = parsed.data.type === "error" ? parsed.data.message : parsed.data.error.message; } } if (failure === undefined) { const stderr = (await this.readOptionalText(stderrPath)) ?? ""; return { state: "failed", error: `codex exited with ${exitCode}: ${stderr.slice(-2000)}`, }; } return { state: "failed", error: failure }; } } Codex exits with a nonzero code when its turn fails, so the exit code decides the outcome. A failed task returns the message from the last turn.failed or error event. Codex retries a failing model request five times, and the task fails after about 20 seconds. Deploy your Worker: The AI_GATEWAY_TOKEN secret from the tutorial stays in place. A running container keeps the image it started with, so use a new sandbox name for Codex. Run a task Clone a repository into a sandbox named codex-1: curl "$WORKER_URL/sandboxes/codex-1/repository" \ --json '{"url": "https://github.com/octocat/Hello-World"}' Start a task: curl "$WORKER_URL/sandboxes/codex-1/task" \ --data "Add a file NOTES.md with a one-sentence summary of this repository. Do not commit." Check the task until its state is succeeded or failed: curl "$WORKER_URL/sandboxes/codex-1/task" A succeeded task includes the final message that Codex wrote. Read the changes: curl "$WORKER_URL/sandboxes/codex-1/diff" The diff shows the new NOTES.md file. Related resources Build a coding agent runner: how the runner starts the sandbox, runs the task, and reads the diff. Codex example: a separate deployable Worker that runs Codex. OpenAI Codex in AI Gateway: route Codex through AI Gateway from your own terminal. Run Cursor Cloud Agents in a sandbox import { Steps } from " /components"; import { VendorAgentConnections } from "/components/react/sandbox/VendorAgentConnections"; Run Cursor Cloud Agents on Cloudflare through self-hosted machines. Each Cursor session assigned to the deployment runs in its own Linux sandbox: a Container that one Durable Object starts for that session. Cursor hosts the agent loop, inference, and planning. Cloudflare runs commands, file edits, repository operations, and other tools inside infrastructure that you control. Prerequisites You need: A Cursor Enterprise plan with self-hosted machines turned on A Cursor team service-account API key with agent scope A Cloudflare Workers Paid account with access to Containers and R2 Node.js 20 or later A running Docker daemon for deployment and local development Configure a Cursor team pool To create a team pool, set CURSOR_API_KEY in your shell. Then, start a local worker with the Cursor Agent CLI: CURSOR_API_KEY="$CURSOR_API_KEY" agent worker --pool cloudflare-test start The command registers the pool and temporarily connects your local machine as a worker. After the pool appears in Cursor, stop the worker with Ctrl+C and run unset CURSOR_API_KEY. Record the pool name for CURSOR_POOL. Keep the local worker stopped while testing the Cloudflare deployment so it does not claim the agent request. For more information, refer to Cursor team pools. Deploy the template The template deploys a Worker, a Durable Object namespace, a container application, an R2 bucket binding, and a cron trigger. 1. Clone the template and install its dependencies: git clone https://github.com/anysphere/cloudflare-workers.git cd cloudflare-workers npm install Log in to your Cloudflare account: npx wrangler login Create the R2 bucket for optional repository snapshots: npx wrangler r2 bucket create cursor-pool-worker-snapshots To use another bucket name, update bucket_name in wrangler.jsonc. Store the required Cursor service-account key as a Worker secret: npx wrangler secret put CURSOR_API_KEY Enter a team service-account key with agent scope. Personal API keys do not work with pool workers. To access private repositories, store your Git credentials as Worker secrets: npx wrangler secret put GIT_USERNAME npx wrangler secret put GIT_TOKEN For GitHub, set GIT_USERNAME to x-access-token. Set GIT_TOKEN to a token with access to the repositories that the agents use. In wrangler.jsonc, set vars.CURSOR_POOL to the Cursor team pool name: { "vars": { "CURSOR_POOL": "default" } } Set containers[].max_instances to the maximum number of concurrent sessions that the deployment must support. Deploy the Worker and container: npx wrangler deploy Wrangler builds the image and deploys the Worker, Durable Object, container application, and cron trigger. A new container image rollout stops running containers. Wait for active Cursor sessions to finish before you deploy an update. Run a repository-bound agent Repository-bound agents route work by Git remote and by team pool name. 1. Go to [Cursor Cloud Agents](https://cursor.com/agents). Start an agent and select a repository. Select Self-hosted, then select the name configured in CURSOR_POOL. Wait for Cursor to assign the session to the deployment. The Worker then starts a container for the session. The initial scheduled controller run can take up to five minutes to begin. The request provides the repository URL. The container restores or clones that repository into $HOME/workspaces/repo-0. It then starts the Cursor worker: agent worker --worker-dir "$HOME/workspaces/repo-0" --pool "$CURSOR_POOL" start --verbose The Cursor worker derives its repository label from the Git remote. Do not configure repo= labels manually. Run an any-repository agent Any-repository agents route work by team pool name. They start with an empty working directory and no Git remote. 1. Go to [Cursor Cloud Agents](https://cursor.com/agents). Start an agent and select the Any repo group. Select the team pool name configured in CURSOR_POOL. The container creates $HOME/workspaces/repo-0 without a Git remote. The agent or a project hook can clone a repository during the session. Configure repository snapshots Repository snapshots are an optional cache for repository-bound agents. A snapshot stores the post-clone working tree in R2. An any-repository agent does not use this cache. 1. Store a secret that protects the snapshot routes: npx wrangler secret put SNAPSHOT_AUTH_TOKEN In wrangler.jsonc, set vars.WORKER_PUBLIC_URL to the deployed Worker URL: { "vars": { "CURSOR_POOL": "default", "WORKER_PUBLIC_URL": "https://cursor-pool-workers..workers.dev" } } Deploy the updated configuration: npx wrangler deploy A cache miss performs a normal Git clone. It does not prevent the agent from starting. How the template works The template manages one container for each assigned Cursor session. Your Worker connects to Cursor on a cron trigger and claims sessions. It sends each session to its own Durable Object, which starts and stops a container. The Cursor worker in the container connects to Cursor and runs the session. Every connection to Cursor starts on Cloudflare, so nothing connects in. A cron trigger runs the Worker every five minutes. Each run lists the sessions that wait for CURSOR_POOL. The run then keeps the server-sent events stream from Cursor open until shortly before the next run, so the Worker also receives sessions that arrive between runs. The Worker claims each session with a unique worker ID. Cursor then assigns the session only to that Worker. A Durable Object for the session starts one container with the session environment and repository information. The container stops after five minutes without work, or after WORKER_IDLE_RELEASE_TIMEOUT_SECONDS if you set that variable. The Durable Object also stops the container after eight hours, or after MAX_RUN_LIFETIME_SECONDS if you set that variable. The container connects out to Cursor, so it needs no inbound port or public IP address. The Worker exposes only its health route and the optional snapshot routes. Security Each session runs in its own container and does not share files or processes with other sessions. The container receives the CURSOR_API_KEY service-account key. It also receives GIT_USERNAME, GIT_TOKEN, and SNAPSHOT_AUTH_TOKEN if you set them. Code that runs in a session can read these values. Give GIT_TOKEN access only to the repositories that the agents use. For more information, refer to Sandbox security. Monitor the deployment To stream controller and container logs, run: npx wrangler tail To list container instances, find the ID of the container application, then list its instances: npx wrangler containers list npx wrangler containers instances To test one scheduled controller run during local development, start wrangler with scheduled-event testing: npx wrangler dev --test-scheduled In another terminal, invoke the scheduled route: curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=*/5+*+*+*+*" If you change the cron interval, update both triggers.crons in wrangler.jsonc and CONTROLLER_RUN_BUDGET_MS in src/config.ts. Troubleshooting Symptom Cause Resolution No sessions are assigned The cron does not run, the key is missing, or the team pool name does not match Run npx wrangler tail. Check controller runs, 401 responses, and the configured team pool name. The controller returns 401 The key is personal or lacks agent scope Replace CURSOR_API_KEY with a team service-account key that has agent scope. The team pool is absent for a repo The worker started without repository labels Select Any repo, or start a repository-bound agent with a configured Git remote. The session is assigned but does not start The container cannot start, clone the repository, or authenticate Run npx wrangler containers list and inspect npx wrangler tail. Check capacity and Git secrets. The container exits with Error: Container exited with unexpected exit code: 1 and an earlier log reports cursor-agent CLI not found on PATH Cloudflare WARP or another TLS-inspecting proxy may have prevented Docker from downloading the Cursor CLI. An unguarded shell pipeline can hide the installation failure and produce an incomplete image. Run npx wrangler tail and inspect the preceding container logs. If the Cursor CLI is missing, disconnect WARP, clear the Docker build cache, and run npx wrangler deploy again. Or configure Docker to trust the root certificate of your organization. The first repository start is slow The snapshot cache is empty or not configured Configure both WORKER_PUBLIC_URL and SNAPSHOT_AUTH_TOKEN, or allow a cold Git clone. Related resources Cursor Cloudflare Workers template Cursor self-hosted machines overview Cursor team pools Cloudflare Containers R2 Run Devin in a sandbox import { PackageManagers, Steps } from " /components"; import { VendorAgentConnections } from "/components/react/sandbox/VendorAgentConnections"; Devin runs its agent loop in its own service. Each session works on a machine where Devin runs commands, edits files, and opens a browser. A Devin Outpost moves that machine to infrastructure that you choose. The Devin Outpost template runs each session in its own Linux sandbox: a Container that one Durable Object starts for that session. Prerequisites You need: A Devin Enterprise organization with permission to manage outposts and service users A Cloudflare account on the Workers Paid plan with access to Containers For manual deployment, Node.js 24 and a running Docker daemon Get your Devin credentials 1. Open your Devin organization outpost settings. In this URL, replace both occurrences of `my-org` with your organization slug: https://my-org.devinenterprise.com/org/my-org/settings/enterprise-environment?tab=outposts Create or select an outpost, and copy its outpost ID for DEVIN_OUTPOST_ID. For DEVIN_API_TOKEN, use a Devin service-user token with the Run outpost workers permission. For more information about outposts, refer to the Devin Outposts overview. Deploy with one click The Deploy to Cloudflare button creates the Worker, the cron trigger, the Durable Object namespace, and the container application. Enter these values when the deployment flow prompts for them: Variable Value DEVIN_OUTPOST_ID Your Devin Outpost ID DEVIN_API_TOKEN A Devin service-user token with the Run outpost workers permission After the deployment finishes, select your outpost from the Virtual environment menu when you create a Devin session. Customize and deploy manually Deploy manually when you need to add dependencies, tools, or environment variables to the container image. 1. Create a project from the Devin Outpost template: Go to the project directory and log in to your Cloudflare account: cd cloudflare-devin-outpost npx wrangler login In wrangler.jsonc, replace the empty DEVIN_OUTPOST_ID value with your outpost ID. The default DEVIN_API_URL is https://api.devin.ai/opbeta. Change this value only if your Devin environment uses another API URL. Store your Devin API token as a Worker secret: npx wrangler secret put DEVIN_API_TOKEN Enter your service-user token when Wrangler prompts you. Do not add the token to wrangler.jsonc. Deploy the Worker and container: npm run deploy Wrangler builds the container image and deploys the Worker, the container application, and the cron trigger. Check the deployment with the Worker URL from the Wrangler output: curl https://.workers.dev/ The Worker returns: { "service": "devin-outpost", "status": "ok" } Run a Devin session In Devin, create a session and select your outpost from the Virtual environment menu. The Worker checks for new sessions every 10 seconds by default, so the sandbox for the session starts shortly after you create it. How the template runs sessions A cron trigger runs the Worker once a minute. During each run, the Worker asks Devin for the sessions of your outpost every 10 seconds, or every DEVIN_RECONCILE_INTERVAL_MS milliseconds if you set that variable. The Worker sends the status of each session to the Durable Object for that session: Devin status What the template does pending, running Starts the sandbox for the session if it is not running, from its snapshot if it has one. suspended Waits for the Devin CLI to exit, saves a snapshot of the sandbox, then stops it. terminated Stops the sandbox and forgets its snapshot. The Durable Object only starts and stops its container. Inside the sandbox, the Devin CLI ( devin worker start) claims the session and runs it. Your Worker connects to Devin on a cron trigger and lists sessions. It sends each session to its own Durable Object, which starts and stops a container. The Devin CLI in the container connects to Devin, claims the session, and runs it. Every connection to Devin starts on Cloudflare, so nothing connects in. When Devin suspends a session, the Devin CLI exits. The sandbox keeps running until the Durable Object saves a snapshot of its disk. When the session continues, the sandbox starts from that snapshot and the Devin CLI claims the session again. The snapshot keeps the files and installed packages of the session. It does not keep running processes. If the Devin CLI exits while the session is still running, the template saves a snapshot and starts a new sandbox from it. For more information, refer to Save and restore a sandbox with snapshots. :::note The template saves a snapshot only after the Devin CLI exits. If a sandbox stops before then, the session continues from its previous snapshot, or from the image if it has none. The Worker API cannot delete snapshots, so the snapshot of a terminated session stays in your account. ::: Security Each session runs in its own container and does not share files or processes with other sessions. Inside the container, Devin runs as root, can reach the Internet, and holds the Devin API token. Code that runs in a session can read that token. Deploy a separate outpost for each group of users that must not share access. For more information, refer to Sandbox security. Related resources Devin Outposts overview Devin Outpost template Containers Save and restore a sandbox with snapshots Run coding agents in a sandbox import { CardGrid, LinkTitleCard } from "~/components"; Coding agents such as Claude Code, Codex, and OpenCode can run a task on their own: they read a repository, edit files, and run commands until the task is done. Run the agent in a Linux sandbox so its commands stay inside a Container that belongs to one task. The agent runs in your sandbox Your Worker starts the sandbox, holds the credentials, and decides which hostnames the sandbox can reach. The agent calls its model over HTTP, for example through AI Gateway. Your Worker adds the API token to each request, so the sandbox never receives it. Each guide in this section changes only the agent-specific parts of one runner. Build the runner first: Then switch the runner to another agent: The vendor runs the agent loop Devin, Cursor, Claude Managed Agents, and the OpenAI Agents API run their agent loop in their own service. The loop sends commands and file edits to sandboxes in your Cloudflare account. Each vendor template gives every session its own sandbox. In the Devin, Cursor, and OpenAI Agents API templates, the vendor worker process runs inside the sandbox, so the sandbox also holds a vendor credential. Build your own agent To give an agent that runs in a Durable Object a container to run commands in, refer to Sandbox in the Agents SDK documentation. Run Codex with the OpenAI Agents API in a sandbox import { CURL, Details, Steps } from "~/components"; The OpenAI Agents API runs the Codex agent loop at OpenAI. With a self-hosted environment, Codex runs commands and edits files in your Cloudflare account instead. The Cloudflare template runs the Codex executor of each session in its own Linux sandbox: a Container that one Durable Object starts for that session. The template contains the Worker and the container image that this guide deploys. How it works OpenAI sends signed webhooks to a Worker in your account as a session starts, needs an environment, works, and goes idle. The Worker verifies each webhook, retrieves the session from OpenAI, and passes it to the Durable Object named for that session. The Durable Object starts a container that runs codex exec-server. The executor connects out to OpenAI with a restricted API key. It runs commands from the agent against files in /workspace, which stay in your Cloudflare account. Prerequisites You need: A Cloudflare account on the Workers Paid plan with access to Containers OpenAI Agents API access and an OpenAI API key curl For manual deployment, Node.js 24 or later and a running Docker daemon You also need a restricted OpenAI API key for codex exec-server, called the executor key in this guide. It needs the api.model.read and api.agents.environments.connect permissions. The OpenAI API key that the Worker uses needs api.agents.read. Both keys must belong to the same organization, project, and owner, whether that owner is a user or a service account. Deploy the template These steps create an OpenAI agent, deploy the Worker and its container with the Deploy to Cloudflare button, register the webhook, and run a test task in /workspace. 1. Set your OpenAI API key, then create an agent: export OPENAI_API_KEY="" Copy the id field from the response and save it as the agent ID: export OPENAI_AGENT_ID="agent_..." Generate a shared secret for the container cleanup endpoint, and save it: openssl rand -hex 32 Select Deploy to Cloudflare: Enter these values when prompted: Variable Value OPENAI_API_KEY The OpenAI key used to retrieve session state OPENAI_EXECUTOR_API_KEY The restricted executor key OPENAI_AGENT_ID The agent ID from step 1 OPENAI_WEBHOOK_SECRET pending-webhook-registration for the first deployment EXECUTOR_CLIENT_SECRET The shared secret from this step Save the deployed Worker URL: export WORKER_URL="https://.workers.dev" The template starts a container as soon as OpenAI creates a session, and snapshots the container when the session goes idle. An idle session keeps its container for EXECUTOR_KEEP_ALIVE_SECONDS, which wrangler.jsonc in the template sets to 30 seconds. In OpenAI project webhook settings, register https://.workers.dev/webhook. OpenAI must be able to reach this URL. Subscribe to these events: agent.session.created agent.session.action_required agent.session.in_progress agent.session.idle agent.session.failed Copy the signing secret that OpenAI returns. In Settings > Variables and Secrets for the Worker, replace OPENAI_WEBHOOK_SECRET with it, then select Deploy. If you deployed manually, run this command in the templates/openai-agents-api directory instead: npx wrangler secret put OPENAI_WEBHOOK_SECRET Check the setup: curl --fail-with-body "$WORKER_URL/health" The Worker is ready when the response contains "configured": true and "webhook_configured": true. Create a self-hosted session: curl https://api.openai.com/v1/agents/sessions \ --request POST \ --header "OpenAI-Beta: agents=v1" \ --header "Authorization: Bearer $OPENAI_API_KEY" \ --json '{"agent_id": "'"$OPENAI_AGENT_ID"'", "environment": {"type": "self_hosted", "workspace_directory": "/workspace"}}' Copy the id field from the response and save it as the session ID: export SESSION_ID="sess_..." Open the session event stream in one terminal: curl --no-buffer \ "https://api.openai.com/v1/agents/sessions/$SESSION_ID/events" \ --header "OpenAI-Beta: agents=v1" \ --header "Authorization: Bearer $OPENAI_API_KEY" \ --header "Accept: text/event-stream" While the stream is open, submit a task from another terminal. Export OPENAI_API_KEY and SESSION_ID in that terminal first: The event stream shows the progress of the agent and its reply. To deploy from your terminal instead of with the button in step 2: 1. Clone the template repository, install its dependencies, and log in to Cloudflare: ```bash git clone https://github.com/cloudflare/sandbox-sdk.git cd sandbox-sdk/templates/openai-agents-api npm install npx wrangler login ``` Generate a shared secret for the container cleanup endpoint, and save it: openssl rand -hex 32 Store the Worker secrets. Enter your OpenAI API key, the executor key, the agent ID, and the shared secret when prompted: npx wrangler secret put OPENAI_API_KEY npx wrangler secret put OPENAI_EXECUTOR_API_KEY npx wrangler secret put OPENAI_AGENT_ID npx wrangler secret put EXECUTOR_CLIENT_SECRET Deploy the Worker and its container: npm run deploy To change the keep-alive, prewarm, and snapshot settings, edit EXECUTOR_KEEP_ALIVE_SECONDS, EXECUTOR_PREWARM_ENABLED, and EXECUTOR_SNAPSHOTS_ENABLED in wrangler.jsonc, then run npm run deploy again. Save the deployed Worker URL, then continue with step 3. Open the session event stream again, then send more input: Build an application on the Agents API The basic example in the template is a TypeScript application that uses the OpenAI Agents API TypeScript SDK. It creates self-hosted sessions that run on the executor Worker. Its HTTP endpoints send the first input, send follow-up input, and clean up. The POST /demo endpoint runs the whole workflow: it creates a session, writes and reads a file in the container, sends a follow-up message, and then deletes the OpenAI session and its executor. Clean up Delete the OpenAI session: To stop the container of a session at once, call the cleanup endpoint with the shared secret from deployment: export WORKER_URL="https://.workers.dev" export EXECUTOR_CLIENT_SECRET="" curl --fail-with-body \ --request DELETE \ --header "Authorization: Bearer $EXECUTOR_CLIENT_SECRET" \ "$WORKER_URL/executors/$SESSION_ID" Deleting an OpenAI session does not send a webhook to the Worker. The Worker stops a container and deletes its record of the container snapshot when it receives an agent.session.failed webhook, when a session lookup returns 404 Not Found, or when you call the cleanup endpoint. An idle session that still exists keeps its snapshot for its next environment connection. Session lifecycle 1. The application creates or retrieves an OpenAI session and sends input through the Agents API. When EXECUTOR_PREWARM_ENABLED is true, as in the template, an agent.session.created webhook makes the Worker retrieve the session and start its container with the environment ID and remote URL of the session. An agent.session.action_required webhook makes the Worker retrieve the session, confirm that the configured agent owns it, and read the environment ID and remote URL that the session needs. The Durable Object named for the session starts a container with those connection details and the executor key. codex exec-server connects out to OpenAI. Each container start, environment connection, and agent.session.in_progress webhook resets a deadline of EXECUTOR_KEEP_ALIVE_SECONDS. When the deadline passes, the Worker retrieves the session. If the session is still active, the Worker sets a new deadline. An agent.session.idle webhook makes the Worker snapshot the container and reset the deadline. When the deadline passes, the Worker stops the container and keeps the snapshot. New input sends another agent.session.action_required webhook. The Worker reuses a running container for the same environment ID, or starts the next environment from the saved snapshot. Keep the workspace between turns :::note[Public beta] Container snapshots are in public beta. Features and behavior may change. ::: When a session goes idle, the Worker snapshots the container filesystem before it stops the container. The next environment connection starts from that snapshot, so the files in /workspace come back. Running processes and memory do not. If the snapshot fails, the Worker leaves the container running and schedules another lifecycle check. For more information, refer to Sandbox lifetime. A session uses its snapshot only while the session lasts. The Worker deletes the snapshot record of a session when the session fails, when a later lookup finds that the session no longer exists, or when you call the cleanup endpoint. The next session then starts from the image. The Worker does not delete the snapshot itself. With EXECUTOR_SNAPSHOTS_ENABLED set to false, each new executor starts with an empty /workspace. To keep files after a session ends, store them somewhere else, such as an R2 bucket. For more information, refer to Mount an R2 bucket. Add tools to the container The templates/openai-agents-api/Dockerfile file in the template defines the executor image. Add Debian packages to its apt-get install command. For example, to add jq and Python: RUN apt-get update \ && apt-get install --yes --no-install-recommends \ ca-certificates \ curl \ git \ jq \ python3 \ ripgrep \ && rm -rf /var/lib/apt/lists/* You can also install language tools in the image, such as global npm packages. Do not put API keys or other secrets in the Dockerfile. Pass them at runtime through Worker secrets and container environment variables. To build and deploy the new image, run npm run deploy in the templates/openai-agents-api directory. New containers start from it. A session that starts from a snapshot keeps the filesystem of its old image, so it does not get the new packages. Security considerations The template is a minimal example. Review these defaults before you adapt it for production: Your OpenAI API key, the webhook secret, and EXECUTOR_CLIENT_SECRET stay in Worker secrets. The executor key goes into the container as CODEX_API_KEY, where every process in the container can read it. Give the executor key only the two permissions it needs. The container has Internet access so that codex exec-server can reach OpenAI. Every command the agent runs has the same access. To restrict destinations or add credentials to requests from your Worker, refer to Outbound traffic. OpenAI must reach /webhook without an interactive Access login. The Worker checks the signature on each webhook, and the cleanup endpoint requires EXECUTOR_CLIENT_SECRET. If you protect other routes with Cloudflare Access, use path-specific policies that leave /webhook reachable. For more information about what a sandbox exposes to the code inside it, refer to Sandbox security. Related resources OpenAI Agents API template OpenAI Agents API documentation OpenAI Python Cloudflare webhook example OpenAI TypeScript Cloudflare webhook example Cloudflare Containers Run OpenCode in a sandbox import { PackageManagers, Steps, WranglerConfig } from "~/components"; Run OpenCode in a Linux sandbox to edit a GitHub repository from a prompt, then read its changes as a diff. OpenCode calls models through AI Gateway. Your Worker adds the gateway token to those requests, and the sandbox can reach only your gateway and github.com. Prerequisites The sandbox-coding-agent project from Build a coding agent runner. This page replaces the image, the model, and the parts of the Durable Object that are specific to the agent. Anthropic credentials in AI Gateway, either Unified Billing credits or an Anthropic API key stored as a provider key. Switch the Worker to OpenCode Replace the Dockerfile. Only the highlighted lines differ from the Dockerfile in the tutorial: FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \ && rm -rf /var/lib/apt/lists/* RUN npm install --global --ignore-scripts opencode-linux-x64-baseline@1.19.4 \ && ln -s /usr/local/lib/node_modules/opencode-linux-x64-baseline/bin/opencode /usr/local/bin/opencode COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace CMD ["sleep", "infinity"] The image installs the baseline x64 OpenCode binary directly. The baseline binary runs on every x64 CPU. The opencode-ai package picks a binary for the CPU of the machine that builds the image. An amd64 build on another architecture runs under an emulator, where the package can pick the wrong binary. OpenCode searches files with rg from the image. Without ripgrep, it tries to download rg from GitHub. The image pins the OpenCode version, because the model list and event format of OpenCode can change between releases. In wrangler.jsonc, set MODEL to an OpenCode model ID for AI Gateway: { "vars": { "AI_GATEWAY_ACCOUNT_ID": "", "AI_GATEWAY_ID": "default", "MODEL": "cloudflare-ai-gateway/anthropic/claude-sonnet-5", }, } OpenCode model IDs for AI Gateway have the form cloudflare-ai-gateway//. OpenCode uses the model list built into the pinned release. To print that list, run opencode models cloudflare-ai-gateway with the same OpenCode version. In src/sandbox.ts, replace the ResultEvent constant with a schema for the OpenCode events that carry the reply and errors: const OpencodeEvent = z.union([ z.object({ type: z.literal("text"), part: z.object({ text: z.string() }) }), z.object({ type: z.literal("error"), error: z.object({ name: z.string(), data: z.object({ message: z.string().optional() }).optional(), }), }), ]); Replace the agentCommand() and agentEnv() methods: export class AgentSandbox extends DurableObject { // ... private agentCommand(prompt: string): string[] { return [ "opencode", // Run one task without an interactive terminal "run", // Write one JSON event per line to the events file "--format", "json", "--auto", // Select the AI Gateway model from `MODEL` "--model", this.env.MODEL, "--", prompt, ]; } private agentEnv(): Record { return { CLOUDFLARE_API_TOKEN: "provided-by-worker", CLOUDFLARE_ACCOUNT_ID: this.env.AI_GATEWAY_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID: this.env.AI_GATEWAY_ID, OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_AUTOUPDATE: "1", OPENCODE_DISABLE_DEFAULT_PLUGINS: "1", OPENCODE_DISABLE_LSP_DOWNLOAD: "1", OPENCODE_DISABLE_SHARE: "1", }; } } --auto approves every tool call that the OpenCode configuration does not deny. Without it, OpenCode rejects permission requests in a run without a terminal. The container limits what commands from OpenCode can reach. The built-in cloudflare-ai-gateway provider in OpenCode reads the account and gateway IDs from the environment. For Anthropic models, it sends requests to the universal endpoint of the gateway: the gateway path with no suffix. The Outbound entrypoint in the runner allows that path and the paths under it. CLOUDFLARE_API_TOKEN is a placeholder, because OpenCode does not start without a token. The Outbound entrypoint replaces the token header with the gateway token. The OPENCODE_DISABLE_* variables make OpenCode use the model list built into the binary and turn off its update check, default plugins, language server downloads, and sharing. OpenCode still tries to install @opencode-ai/plugin from npm in the background. That request gets a 403 response from the Outbound entrypoint, and OpenCode continues. Replace the outcome() method: export class AgentSandbox extends DurableObject { // ... private async outcome(exitCode: number): Promise { let text = ""; let error: string | undefined; const events = await this.files.readFile(eventsPath); for await (const line of readLines(events.body)) { const parsed = OpencodeEvent.safeParse(parseJson(line)); if (!parsed.success) { continue; } if (parsed.data.type === "text") { text = parsed.data.part.text; } else { error = parsed.data.error.data?.message ?? parsed.data.error.name; } } if (exitCode === 0) { return { state: "succeeded", result: text }; } if (error === undefined) { const stderr = (await this.readOptionalText(stderrPath)) ?? ""; return { state: "failed", error: `opencode exited with ${exitCode}: ${stderr.slice(-2000)}`, }; } return { state: "failed", error }; } } OpenCode exits with a nonzero code on any model or session error, so the exit code decides the outcome. A successful task returns the last text event. A failed task returns the message from the last error event, such as the message from the gateway for a rejected token or missing Unified Billing credits. Deploy your Worker: The AI_GATEWAY_TOKEN secret from the tutorial stays in place. A running container keeps the image it started with, so use a new sandbox name for OpenCode. Run a task Clone a repository into a sandbox named opencode-1: curl "$WORKER_URL/sandboxes/opencode-1/repository" \ --json '{"url": "https://github.com/octocat/Hello-World"}' Start a task: curl "$WORKER_URL/sandboxes/opencode-1/task" \ --data "Add a file NOTES.md with a one-sentence summary of this repository. Do not commit." Check the task until its state is succeeded or failed: curl "$WORKER_URL/sandboxes/opencode-1/task" A succeeded task includes the last text reply from OpenCode. Read the changes: curl "$WORKER_URL/sandboxes/opencode-1/diff" The diff shows the new NOTES.md file. Related resources Build a coding agent runner: how the runner starts the sandbox, runs the task, and reads the diff. OpenCode example: a separate deployable Worker that runs OpenCode. OpenCode documentation Run Pi in a sandbox import { PackageManagers, Steps, WranglerConfig } from "~/components"; Run the Pi coding agent in a Linux sandbox to edit a GitHub repository from a prompt, then read its changes as a diff. Pi calls models through AI Gateway with its built-in provider. Your Worker adds the gateway token to those requests, and the sandbox can reach only your gateway and github.com. Prerequisites The sandbox-coding-agent project from Build a coding agent runner. This page replaces the image, the model, and the parts of the Durable Object that are specific to the agent. Anthropic credentials in AI Gateway, either Unified Billing credits or an Anthropic API key stored as a provider key. Switch the Worker to Pi Replace the Dockerfile. Only the highlighted line differs from the Dockerfile in the tutorial: FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \ && rm -rf /var/lib/apt/lists/* RUN npm install --global --ignore-scripts @earendil-works/pi-coding-agent@0.87.1 COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace CMD ["sleep", "infinity"] The image pins the version, because the flags and event format of Pi can change between releases. In wrangler.jsonc, set MODEL to a model ID from the cloudflare-ai-gateway provider in Pi: { "vars": { "AI_GATEWAY_ACCOUNT_ID": "", "AI_GATEWAY_ID": "default", "MODEL": "claude-sonnet-5", }, } Use a model from a provider such as Anthropic. For most Workers AI models in its catalog, Pi 0.87.1 requests as many output tokens as the context window. Workers AI rejects those requests. In src/sandbox.ts, replace the ResultEvent constant with a schema for the Pi events that report the outcome: const PiEvent = z.union([ z.object({ type: z.literal("agent_settled") }), z.object({ type: z.literal("message_end"), message: z.object({ role: z.literal("assistant"), content: z.array( z.object({ type: z.string(), text: z.string().optional() }), ), stopReason: z.string(), errorMessage: z.string().optional(), }), }), ]); type PiMessage = Extract< z.infer, { type: "message_end" } >["message"]; Replace the agentCommand() and agentEnv() methods: export class AgentSandbox extends DurableObject { // ... private agentCommand(prompt: string): string[] { return [ "pi", // Run one task, write one JSON event per line to the events file, and exit "--mode", "json", // Keep the session in memory instead of saving it to disk "--no-session", // Select the built-in AI Gateway provider in Pi, which reads the account // and gateway IDs from the environment "--provider", "cloudflare-ai-gateway", // Select the model from `MODEL` "--model", this.env.MODEL, "--", prompt, ]; } private agentEnv(): Record { return { CLOUDFLARE_API_KEY: "provided-by-worker", CLOUDFLARE_ACCOUNT_ID: this.env.AI_GATEWAY_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID: this.env.AI_GATEWAY_ID, PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", }; } } Pi runs its tools without approval prompts. The container limits what commands from Pi can reach. Pi does not start without an API key, so CLOUDFLARE_API_KEY is a placeholder. Pi sends the key in the cf-aig-authorization header, and the Outbound entrypoint replaces it with the gateway token. PI_OFFLINE, PI_SKIP_VERSION_CHECK, and PI_TELEMETRY turn off the model catalog refresh, version check, and telemetry in Pi. Pi still loads AGENTS.md and CLAUDE.md files from the repository. Without the --approve flag, it skips the protected project resources in the .pi directory of the repository. Leave --approve off for repositories you do not trust. Replace the outcome() method: export class AgentSandbox extends DurableObject { // ... private async outcome(exitCode: number): Promise { if (exitCode !== 0) { const stderr = (await this.readOptionalText(stderrPath)) ?? ""; return { state: "failed", error: `pi exited with ${exitCode}: ${stderr.slice(-2000)}`, }; } let settled = false; let final: PiMessage | undefined; const events = await this.files.readFile(eventsPath); for await (const line of readLines(events.body)) { const parsed = PiEvent.safeParse(parseJson(line)); if (!parsed.success) { continue; } if (parsed.data.type === "agent_settled") { settled = true; } else { final = parsed.data.message; } } if (!settled || final === undefined) { return { state: "failed", error: "pi exited before the agent settled" }; } if (final.stopReason === "error" || final.stopReason === "aborted") { return { state: "failed", error: final.errorMessage ?? `pi stopped: ${final.stopReason}`, }; } const text = final.content.flatMap((block) => block.type === "text" ? [block.text ?? ""] : [], ); return { state: "succeeded", result: text.join("") }; } } Pi exits successfully even when a model request fails, so the outcome comes from its events. The task succeeds when Pi sends an agent_settled event and its last assistant message has a stopReason other than error or aborted. Otherwise, the task fails with the error message from Pi. Deploy your Worker: The AI_GATEWAY_TOKEN secret from the tutorial stays in place. A running container keeps the image it started with, so use a new sandbox name for Pi. Run a task Clone a repository into a sandbox named pi-1: curl "$WORKER_URL/sandboxes/pi-1/repository" \ --json '{"url": "https://github.com/octocat/Hello-World"}' Start a task: curl "$WORKER_URL/sandboxes/pi-1/task" \ --data "Add a file NOTES.md with a one-sentence summary of this repository. Do not commit." Check the task until its state is succeeded or failed: curl "$WORKER_URL/sandboxes/pi-1/task" A succeeded task includes the text of the last reply from Pi. Read the changes: curl "$WORKER_URL/sandboxes/pi-1/diff" The diff shows the new NOTES.md file. Related resources Build a coding agent runner: how the runner starts the sandbox, runs the task, and reads the diff. Pi example: a separate deployable Worker that runs Pi. Pi in AI Gateway: route Pi through AI Gateway from your own terminal. Follow a background process import { PackageManagers, Steps, TypeScriptExample } from "~/components"; Read the output of a background process while it runs, from a request other than the one that started it. A page can show each line as the process prints it, and your Worker can wait until a process prints a line, such as ready from a development server. Prerequisites A Worker that runs background processes, with the scripts, run(), status(), and the routes from Run background processes. Follow the output Add a method that sends the output of a process as server-sent events while the process runs: export class MyContainer extends DurableObject { // ... async follow(id: string, stream: "stdout" | "stderr"): Promise { const status = await this.status(id); if (!status) { return undefined; } const path = `${ROOT}/${id}/${stream}.log`; const argv = status.state === "running" ? ["tail", "-n", "+1", "-F", "--pid", `${status.pid}`, path] : ["cat", path]; const process = await this.ctx.container!.exec(argv, { stderr: "ignore" }); const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder(); const send = (event: string, data: unknown) => writer.write( encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), ); let exited = false; const markExited = () => { exited = true; }; process.exitCode.then(markExited, markExited); // Signaling a process that has exited records an internal error. const stop = () => { if (!exited) process.kill(); }; // Stop following when the client disconnects. writer.closed.catch(stop); // Detect a disconnected client while the log is quiet. const heartbeat = setInterval(() => { writer.write(encoder.encode(": keep-alive\n\n")).catch(() => {}); }, 5_000); const pump = async () => { try { for await (const text of process.stdout!.pipeThrough( new TextDecoderStream(), )) { await send(stream, text); } const ended = await this.status(id); const exitCode = ended?.state === "exited" ? ended.exitCode : null; await send("exit", { exitCode }); await writer.close(); } catch (error) { stop(); // End the response, so a connected client does not wait forever. await writer.abort(error).catch(() => {}); } finally { clearInterval(heartbeat); } }; void pump(); return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }, }); } } follow() sends the output so far, then new output until the process exits and tail --pid stops. Each event is named after the stream, and holds a JSON-encoded chunk of output, which can be part of a line or several lines. When the output ends, follow() sends an exit event with the exit code, or null when the process recorded none. A page can read the stream with EventSource, as in Stream command output. follow() notices a disconnected client only when a write fails. The keep-alive comment every five seconds makes that write happen while the log is quiet, so follow() stops tail. Without the comment, tail keeps running until the process exits. In the logs branch of your Worker route, follow the output when the query has follow. Add this before the call to sandbox.logs(): if (url.searchParams.has("follow")) { const response = await sandbox.follow(id, stream); return response ?? new Response("Not found", { status: 404 }); } Deploy your Worker: Start a process named ticker, then follow its output. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/processes/ticker --request POST curl "https://..workers.dev/processes/ticker/logs?follow" --no-buffer The second request prints the lines so far as one event, then a new event each second: event: stdout data: "ready\ntick 1\ntick 2\ntick 3\ntick 4\n" event: stdout data: "tick 5\n" event: stdout data: "tick 6\n" Add stream=stderr to the query to follow standard error. In another terminal, stop the process: curl https://..workers.dev/processes/ticker --request DELETE The followed output ends with an exit event: event: exit data: {"exitCode":143} Wait for a log line A development server prints a line, such as ready, when it accepts requests. To wait for that line before you use the server, check the log files until a line matches or the process exits: Add a script that checks the logs of a process: // Prints the first matching line and exits with 0. Exits with 3 when the // process ends without printing one, 4 when there is no such process, and // 5 when the process was lost, for example in an earlier instance. const WAIT = `${CURRENT} dir=$1; pattern=$2 [ -d "$dir" ] || exit 4 while :; do exited=false; [ -e "$dir/exit-code" ] && exited=true if ! $exited && [ -e "$dir/pid" ] && ! { current "$dir" && kill -0 "$pid" 2>/dev/null; }; then sleep 0.1; [ -e "$dir/exit-code" ] || exit 5; exited=true fi line=$(grep -h -m 1 -E -e "$pattern" "$dir/stdout.log" "$dir/stderr.log" 2>/dev/null | head -n 1) [ -n "$line" ] && { printf '%s\\n' "$line"; exit 0; } $exited && exit 3 sleep 0.2 done`; Do not pipe tail -F into grep -m 1 instead: tail keeps running after grep exits, until the next line arrives. Add a method that runs the script with a time limit: export class MyContainer extends DurableObject { // ... async waitForLog(id: string, pattern: string, timeoutMs: number) { const container = this.ctx.container; if (!container?.running) { return undefined; } const result = await run(container, [ "timeout", "--kill-after=5", `${timeoutMs / 1000}`, "sh", "-c", WAIT, "sh", `${ROOT}/${id}`, pattern, ]); if (result.exitCode === 0) { return { state: "matched", line: result.stdout.trimEnd() }; } if (result.exitCode === 3) { return { state: "exited" }; } if (result.exitCode === 4) { return undefined; } if (result.exitCode === 5) { return { state: "lost" }; } // 124 when the time limit passes, or 137 if timeout also sends SIGKILL. if (result.exitCode === 124 || result.exitCode === 137) { return { state: "timed-out" }; } throw new Error(`The log check failed with exit code ${result.exitCode}`); } } timeout stops the script when the time limit passes. If the script is still running five seconds later, --kill-after=5 stops it with SIGKILL. Call it after startProcess(), for example with the pattern ^ready: const ready = await sandbox.waitForLog(id, "^ready", 30_000); { "state": "matched", "line": "ready" } The pattern is an extended regular expression. The state is exited when the process ends without printing a matching line, lost when it ended without recording an exit code, and timed-out when the time limit passes first. The method returns undefined when no process uses the ID. A process that was still running when an earlier instance stopped is lost, even when its log matches, because current() checks the boot ID. Related resources Run background processes: start, check, and stop the processes that this page follows. Stream command output: stream the output of a command that finishes while the request is open. Run a server in the background: processes that run until the container stops. Run commands in a sandbox Each of the following guides runs a command in a sandbox with exec(). They differ in how the output reaches the caller and in what ends the command: Guide How the output reaches the caller The command runs until Run Python code In the response, after the code exits The code exits Run tests from a Git repository In the response, after the tests finish The tests finish Stream command output As server-sent events, while the command runs It exits, or a disconnect sends it SIGTERM Run background processes In files that later requests read It exits, or a later request stops it Follow a background process As server-sent events, from those files It exits, or a later request stops it Run a server in the background Over the server port, and in a log file The container stops, or a request stops it Open a terminal in the browser In an interactive terminal, over a WebSocket You run exit, or your Worker ends the session For every option that exec() accepts, refer to Execute commands in the Containers documentation. Open a terminal in the browser import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from " /components"; import { BrowserTerminal } from "/components/react/sandbox/BrowserTerminal"; 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. 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. Open a terminal Create a Dockerfile in the project root with tmux: 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 infinity keeps the container running so that it can accept exec() calls for each terminal. In wrangler.jsonc, build the Dockerfile as a named image in your containers entry, then generate types for it: { "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "terminal": { "dockerfile": "./Dockerfile", }, }, }, ], } Add an inactivity timeout to your Durable Object. The constructor sets the timeout again when a restarted Durable Object finds the container running: const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; export class MyContainer extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (container?.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } } The 5-minute timeout gives a reloaded page or a dropped connection time to attach to the same tmux session. Then add a fetch() handler that attaches a WebSocket to a tmux session: export class MyContainer extends DurableObject { // ... async fetch(request: Request): Promise { const container = this.ctx.container; if (!container) { throw new Error("No container is 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 -A attaches 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. The user option of exec(), such as user: "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. Create a terminal page: Sandbox terminal
The page sends keystrokes as binary messages and window sizes as JSON text messages. When the connection closes, the page prints the reason, and a reload connects again. For a page that reconnects by itself, refer to the terminal workspace example. The page loads xterm.js from a CDN. In production, install @xterm/xterm and @xterm/addon-fit from npm and serve them with static assets. Add routes to your Worker that serve the page and pass the WebSocket to the sandbox: const page = `[... HTML from step 4 ...]`; export default { async fetch(request: Request, env: Env): Promise { 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; Anyone who opens the page gets a root shell 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: Open the terminal for the sandbox named ada in your browser. Replace the example hostname with the workers.dev URL that Wrangler prints, and keep the trailing slash: https://..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 as 32 109. Resize the browser window and run stty size again. 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; done The 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 q to 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=build to the URL. Run exit in the shell to end the session. The page shows [Terminal closed with code 0]. To leave the session running, detach with CTRL + B then D, and reload the page to attach again. Run another command in the terminal 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(). End a session from your Worker To end a session 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. Related resources 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. Run a server in the background import { PackageManagers, Steps, TypeScriptExample } from "~/components"; Start a server that runs until the container stops, such as a development server, a tunnel, or a code kernel. Unlike a job, nothing waits for the server to exit. Your Durable Object starts it on first use, waits until it answers, and starts it again in each new container. For a process that runs until it finishes, such as a build or an agent task, refer to Run background processes. If the sandbox runs one server and keeps no files that you need, you can make the server the main process instead, as in Preview a web application. When the main process exits, the instance stops, and its files end with it. 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. Run a server Add shell scripts and functions that start a server, check it, and stop it: const SERVERS = "/var/lib/servers"; const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // Defines current(), which reads the process ID in a directory into $pid // when the process started in this instance. const CURRENT = `current() { read -r pid boot 2>/dev/null <"$1/pid" && [ "$boot" = "$(cat /proc/sys/kernel/random/boot_id)" ] }`; // Starts the server unless it already runs. The server runs in its own // process group, and writes its process ID and output to its directory. const SERVE = `${CURRENT} dir=$1; shift current "$dir" && kill -0 "$pid" 2>/dev/null && exit 0 mkdir -p "$dir" && rm -f "$dir/pid" setsid sh -c 'echo "$$ $(cat /proc/sys/kernel/random/boot_id)" >"$0/pid"; exec "$@"' \\ "$dir" "$@" >"$dir/log" 2>&1`; // Prints the end of the log and exits with 0 when the server has exited. const EXITED = `${CURRENT} current "$1" || exit 1 kill -0 "$pid" 2>/dev/null && exit 1 tail -n 20 "$1/log"`; // Stops the process group, with SIGKILL if the server still runs after // 10 seconds. Once the server exits, removes its process ID. const STOP = `${CURRENT} current "$1" || exit 0 kill -s TERM -- "-$pid" 2>/dev/null timeout 10 sh -c 'while kill -0 "$0" 2>/dev/null; do sleep 0.1; done' "$pid" || kill -s KILL -- "-$pid" 2>/dev/null while kill -0 "$pid" 2>/dev/null; do sleep 0.1; done rm -f "$1/pid"`; type ServerOptions = { cwd?: string; env?: Record }; async function startServer( container: Container, dir: string, argv: string[], options: ServerOptions = {}, ): Promise { // Ignoring the output lets the server outlive this request. await container.exec(["sh", "-c", SERVE, "sh", dir, ...argv], { ...options, stdout: "ignore", stderr: "ignore", }); } async function serverExited( container: Container, dir: string, ): Promise { const check = await container.exec(["sh", "-c", EXITED, "sh", dir]); const output = await check.output(); return output.exitCode === 0 ? new TextDecoder().decode(output.stdout) : undefined; } async function waitForServer( container: Container, dir: string, port: number, timeoutMs: number, ): Promise { const server = container.getTcpPort(port); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const response = await server.fetch("http://server/", { signal: AbortSignal.timeout(1_000), }); await response.body?.cancel(); return server; } catch { const log = await serverExited(container, dir); if (log !== undefined) { throw new Error(`The server exited: ${log}`); } await scheduler.wait(500); } } throw new Error("The server did not answer in time"); } async function stopServer(container: Container, dir: string): Promise { const stop = await container.exec(["sh", "-c", STOP, "sh", dir]); const exitCode = await stop.exitCode; if (exitCode !== 0) { throw new Error(`Stopping the server failed with exit code ${exitCode}`); } } SERVE starts nothing when the server in the directory already runs, and a new container, which has no server, gets one. This also covers a Durable Object that restarts while its container keeps running. The process ID file also records the boot ID, which changes in every instance. A snapshot restores the file, and a new instance reuses the same process IDs, so a restored process ID can belong to an unrelated process. current() ignores a process ID from another instance. setsid starts the server in a new process group, so STOP also stops the processes that the server starts. The shell that runs SERVE stays in the container until the server exits. Do not start the server with & instead: a shell starts background jobs with SIGINT ignored. STOP removes the process ID once the server exits, so a server started in the same directory right after it never reads as exited. A server that ignores SIGTERM receives SIGKILL after 10 seconds. waitForServer() treats any HTTP response as ready. If the server exits first, it throws with the end of the log. The log grows until the server stops, so keep the output of a busy server small, or send it to a mounted bucket. Add a constructor to your Durable Object. It sets the inactivity timeout again when a restarted Durable Object finds the container running: export class MyContainer extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (container?.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } } Then add methods that send a request to the server, starting it first, and stop it: const WEB = `${SERVERS}/web`; const WEB_PORT = 8080; const WEB_SERVER = [ "node", "--eval", `require("node:http") .createServer((req, res) => res.end("hello from the server\\n")) .listen(${WEB_PORT})`, ]; export class MyContainer extends DurableObject { // ... // The server start that requests in progress share private starting?: Promise; async fetchServer(request: Request): Promise { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } if (!container.running) { container.start({ image: "cloudflare/debian-trixie", entrypoint: ["sleep", "infinity"], enableInternet: false, }); await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } this.starting ??= startServer(container, WEB, WEB_SERVER) .then(() => waitForServer(container, WEB, WEB_PORT, 30_000)) .finally(() => { this.starting = undefined; }); const server = await this.starting; return server.fetch(request); } async stopWeb(): Promise { const container = this.ctx.container; if (container?.running) { await stopServer(container, WEB); } } } Requests that arrive together share one start. startServer() returns before the server records its process ID, so a second SERVE in that window would start a second server. The second server fails on the port, overwrites the process ID file, and leaves STOP unable to stop the first one. After the server answers, each request runs SERVE again, which starts nothing while the server runs and restarts it if it exited. A running server does not keep the container running. Requests that reach the server through your Durable Object keep it running, as other requests do. When the inactivity timeout ends, the container stops, and the server stops with it. For more information, refer to Sandbox lifetime. Add routes to your Worker that call these methods: export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); if (url.pathname !== "/server") { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName("sandbox"); if (request.method === "DELETE") { await sandbox.stopWeb(); return new Response(null, { status: 202 }); } // getTcpPort() accepts only http:// URLs. const forward = new Request("http://container/", request); return sandbox.fetchServer(forward); }, } satisfies ExportedHandler; Deploy your Worker: Send a request to the server. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/server The first request starts the container and the server, and waits until the server answers: hello from the server Stop the server, then send another request: curl https://..workers.dev/server --request DELETE curl https://..workers.dev/server The DELETE request responds with 202. The next request starts the server again and prints the same response. Related resources Run background processes: processes that run until they finish. Preview a web application: a web server as the main process. Sandbox lifetime getTcpPort() Run background processes import { PackageManagers, Steps, TypeScriptExample } from "~/components"; Start a process that keeps running after the request ends, such as a build, a test suite, or an agent task. The process object that exec() returns belongs to the request that started it, so later requests manage the process through files in the sandbox. The process writes its process ID, output, and exit code to those files. For a process that runs until the container stops, such as a development server or a tunnel, refer to Run a server in the background. 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. Run a process in the background Add shell scripts that run a process and report its state: const ROOT = "/var/lib/processes"; const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // Runs the command in its own process group. Records its process ID when // it starts and its exit code when it ends. const RUN = `dir=$1; shift setsid sh -c 'echo "$$ $(cat /proc/sys/kernel/random/boot_id)" >"$0/pid"; exec "$@"' \\ "$dir" "$@" >"$dir/stdout.log" 2>"$dir/stderr.log" echo "$?" >"$dir/exit-code.tmp" && mv "$dir/exit-code.tmp" "$dir/exit-code"`; // Defines current(), which reads the process ID in a directory into $pid // when the process started in this instance. const CURRENT = `current() { read -r pid boot 2>/dev/null <"$1/pid" && [ "$boot" = "$(cat /proc/sys/kernel/random/boot_id)" ] }`; // Prints the state of the process that owns the directory. const STATUS = `${CURRENT} dir=$1 if [ ! -d "$dir" ]; then echo missing elif [ -e "$dir/exit-code" ]; then echo "exited $(cat "$dir/exit-code")" elif [ ! -e "$dir/pid" ]; then echo starting elif current "$dir" && kill -0 "$pid" 2>/dev/null; then echo "running $pid" elif [ -e "$dir/exit-code" ]; then echo "exited $(cat "$dir/exit-code")" else echo lost fi`; type Status = | { state: "starting" | "lost" } | { state: "running"; pid: number } | { state: "exited"; exitCode: number }; async function run(container: Container, argv: string[]) { const process = await container.exec(argv); const output = await process.output(); return { exitCode: output.exitCode, stdout: new TextDecoder().decode(output.stdout), }; } setsid starts the command in a new process group. Stopping the group also stops the processes the command starts, such as the node process behind npm run dev, unless a process moves to its own group. The script writes the exit code to a temporary file and then renames it, so a reader never sees a partial file. Do not start the command with & instead: a shell starts background jobs with SIGINT ignored. Each process keeps its files in its own directory under /var/lib/processes on the instance disk. The logs grow until the process ends. When the disk fills, writes from the process fail, so delete old process directories, or send output that you want to keep to a mounted bucket. Add a constructor to your Durable Object. It sets the inactivity timeout again when a restarted Durable Object finds the container running: export class MyContainer extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (container?.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } } Then add a method that starts a process: export class MyContainer extends DurableObject { // ... async startProcess(id: string, argv: string[]): Promise { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } if (!container.running) { container.start({ image: "cloudflare/debian-trixie", entrypoint: ["sleep", "infinity"], enableInternet: false, }); await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } // mkdir fails when another process already uses the ID. const dir = `${ROOT}/${id}`; await run(container, ["mkdir", "-p", ROOT]); const created = await run(container, ["mkdir", dir]); if (created.exitCode !== 0) { return false; } // Ignoring the output lets the process outlive this request. await container.exec(["sh", "-c", RUN, "sh", dir, ...argv], { stdout: "ignore", stderr: "ignore", }); // exec() returns before the process records its ID. Wait for it, so // a request that follows the output right away finds it running. for (let i = 0; i < 50; i++) { if ((await this.status(id))?.state !== "starting") break; await scheduler.wait(100); } await this.ctx.storage.put(`process:${id}`, argv); await this.ctx.storage.setAlarm(Date.now() + 60_000); return true; } } Add a method that reports the state of a process: export class MyContainer extends DurableObject { // ... async status(id: string): Promise { const container = this.ctx.container; if (!container?.running) { return undefined; } const { stdout } = await run(container, [ "sh", "-c", STATUS, "sh", `${ROOT}/${id}`, ]); const [state, value] = stdout.trim().split(" "); if (state === "running") { return { state, pid: Number(value) }; } if (state === "exited") { return { state, exitCode: Number(value) }; } if (state === "starting" || state === "lost") { return { state }; } return undefined; } } kill -0 checks that the process exists without sending it a signal. A process is lost when it ended without recording an exit code, for example because something sent SIGKILL to the script that records it. The process ID file also records the boot ID, which changes in every instance. A snapshot restores the file, and a new instance reuses the same process IDs, so a restored process ID can belong to an unrelated process. current() ignores a process ID from another instance, so a process that was running when the snapshot was taken is lost. Add a method that returns the output of a process so far: export class MyContainer extends DurableObject { // ... async logs(id: string, stream: "stdout" | "stderr"): Promise { if (!(await this.status(id))) { return undefined; } const path = `${ROOT}/${id}/${stream}.log`; return (await run(this.ctx.container!, ["cat", path])).stdout; } } To stream the output while the process runs, or to wait until it prints a line, refer to Follow a background process. Add a method that stops a process: export class MyContainer extends DurableObject { // ... async stop(id: string): Promise { const status = await this.status(id); if (status?.state !== "running") { return false; } // `kill` is a shell built-in. A negative ID signals the process group. const { exitCode } = await run(this.ctx.container!, [ "sh", "-c", 'kill -s TERM -- "$1"', "sh", `-${status.pid}`, ]); return exitCode === 0; } } A process stopped by a signal exits with 128 plus the signal number, such as 143 for SIGTERM. Add an alarm handler that keeps the sandbox running while any process runs: export class MyContainer extends DurableObject { // ... async alarm(): Promise { let running = false; for (const [key, argv] of await this.ctx.storage.list({ prefix: "process:", })) { const id = key.slice("process:".length); const status = await this.status(id); if (status?.state === "running" || status?.state === "starting") { running = true; } else { console.log({ event: "process.ended", id, argv, status }); await this.ctx.storage.delete(key); } } if (running) { await this.ctx.storage.setAlarm(Date.now() + 60_000); } } } A running process does not keep the container running. The alarm checks every process each minute, and each alarm keeps the container running. A Durable Object that restarted between alarms sets the 10-minute inactivity timeout again in its constructor. The container stops 10 minutes after the alarm finds that the last process has ended. For more information, refer to Sandbox lifetime. A Durable Object has one alarm. If your class already has an alarm() handler, merge the process checks into it, and set the alarm to the earliest time that any check needs. For more information, refer to Alarms. When a process ends, the alarm writes a process.ended log to Workers Logs within a minute. Replace the console.log() call with what your application does next, such as sending a notification. Add routes to your Worker that call these methods: const command = [ "sh", "-c", "echo ready; for i in $(seq 1 300); do echo tick $i; sleep 1; done", ]; export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/processes\/([a-z0-9-]{1,63})(\/logs)?$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const [, id, logs] = match; const sandbox = env.MY_CONTAINER.getByName("sandbox"); if (logs) { const stream = url.searchParams.get("stream") === "stderr" ? "stderr" : "stdout"; const output = await sandbox.logs(id, stream); return output === undefined ? new Response("Not found", { status: 404 }) : new Response(output); } if (request.method === "POST") { return (await sandbox.startProcess(id, command)) ? new Response(null, { status: 201 }) : new Response("The process ID is in use", { status: 409 }); } if (request.method === "DELETE") { return (await sandbox.stop(id)) ? new Response(null, { status: 202 }) : new Response("The process is not running", { status: 409 }); } const status = await sandbox.status(id); return status ? Response.json(status) : new Response("Not found", { status: 404 }); }, } satisfies ExportedHandler; This example command prints ready, then prints a line each second for five minutes. Replace it with your own command. The route accepts only process IDs that are safe in a path, because each ID becomes a directory name. Deploy your Worker: Start a process named ticker, then check it. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/processes/ticker --request POST curl https://..workers.dev/processes/ticker The first request responds with 201, and a second POST with the same ID responds with 409. The status includes the process ID: { "state": "running", "pid": 354 } Read the output so far: curl https://..workers.dev/processes/ticker/logs The request prints the lines so far and ends: ready tick 1 tick 2 tick 3 Add stream=stderr to the query to read standard error. Stop the process, then check it again: curl https://..workers.dev/processes/ticker --request DELETE curl https://..workers.dev/processes/ticker The DELETE request responds with 202. The status shows the exit code: { "state": "exited", "exitCode": 143 } Related resources Follow a background process: stream the output of a process while it runs, or wait until it prints a line. Stream command output: commands that finish while the request is open. Run a server in the background: processes that run until the container stops. Preview a web application: a web server as the main process. Process workspace example: a deployable Worker that runs processes with the same approach, plus routes that list processes and wait for one to exit. Its scripts do not record the boot ID. Sandbox lifetime Durable Object alarms Run Python code import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; Run Python code in a sandbox and return its output. The image sets the Python version and the packages the code can import. Files that one run writes stay in the sandbox for the next run. 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. Run Python code 1. Create a file named `sales.py` with the Python code to run. In this example, the code uses `pandas`: import pandas as pd sales = pd.DataFrame({"city": ["Lisbon", "Austin", "Lisbon"], "units": [3, 5, 4]}) print(sales.groupby("city")["units"].sum()) Create a Dockerfile in your project root with Python and the packages the code can import: FROM python:3.13-slim RUN pip install --no-cache-dir numpy pandas matplotlib WORKDIR /workspace CMD ["sleep", "infinity"] Installing packages in the image makes them available as soon as the sandbox starts, without Internet access in the sandbox. In wrangler.jsonc, build the Dockerfile as a named image in your containers entry, then generate types: { "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "python": { "dockerfile": "./Dockerfile", }, }, }, ], } Add an inactivity timeout to your Durable Object. The constructor sets the timeout again when a restarted Durable Object finds the container running: const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (container?.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } } Then add a method that runs Python code: export class MyContainer extends DurableObject { // ... async runPython(code: string) { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } if (!container.running) { container.start({ image: container.images.python, // Block the code from reaching the Internet enableInternet: false, }); await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } // Each run is a new Python process, so variables do not carry over const process = await container.exec( [ // GNU coreutils `timeout` stops code that runs longer than 60 seconds "timeout", "--kill-after=5", "60", // `python3 -` reads the code from standard input "python3", "-", ], { stdin: new Response(code).body!, // Files that the code writes here stay until the instance stops cwd: "/workspace", }, ); const output = await process.output(); const decoder = new TextDecoder(); return { stdout: decoder.decode(output.stdout), // A Python exception prints its traceback here stderr: decoder.decode(output.stderr), // `124` or `137` if the code timed out, `1` if it raised an exception exitCode: output.exitCode, }; } } For how timeout stops the processes that the code starts, refer to Stop the processes a command starts. output() holds everything the code prints in the memory of the Durable Object. For code that prints a lot, stream the output instead. Add a route to your Worker that runs the request body as Python code: export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/python$/.exec(url.pathname); if (!match || request.method !== "POST") { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(match[1]); return Response.json(await sandbox.runPython(await request.text())); }, } satisfies ExportedHandler; The code can read every file in its sandbox, so give each user their own sandbox name, and authenticate callers first. For more information, refer to Sandbox security. Deploy your Worker: Run sales.py in a sandbox named ada. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/sandboxes/ada/python --data-binary @sales.py { "stdout": "city\nAustin 5\nLisbon 7\nName: units, dtype: int64\n", "stderr": "", "exitCode": 0 } Return a chart To return an image, such as a matplotlib chart, have the code save it in /workspace, then read the file in another request: Add a method to your Durable Object that reads a file from /workspace: export class MyContainer extends DurableObject { // ... async readFile(name: string) { const container = this.ctx.container; if (!container?.running) { return null; } const process = await container.exec(["cat", "--", name], { cwd: "/workspace", stderr: "ignore", }); const output = await process.output(); return output.exitCode === 0 ? output.stdout : null; } } In the fetch() handler of your Worker, after the line that parses url, add a route that returns the chart: const chart = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/chart\.png$/.exec(url.pathname); if (chart) { const png = await env.MY_CONTAINER.getByName(chart[1]).readFile("chart.png"); return png ? new Response(png, { headers: { "Content-Type": "image/png" } }) : new Response("Not found", { status: 404 }); } Run code that saves a chart, then open /sandboxes/ada/chart.png in your browser: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt plt.bar(["Austin", "Lisbon"], [5, 7]) plt.savefig("chart.png") curl https://..workers.dev/sandboxes/ada/python --data-binary @chart.py matplotlib.use("Agg") draws without a display. For larger files and uploads, refer to Move files in and out of a sandbox. Run another language To run another language, change the base image and the command that reads standard input. For example, use FROM ruby:3.4-slim and ["ruby", "-"], or FROM node:24-slim and ["node", "-"]. Related resources Build an AI code interpreter: run code that a model writes in a Dynamic Worker. Stream command output: show output while long code runs. Save and restore a sandbox with snapshots: keep files in /workspace after the instance stops. Run tests from a Git repository import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; Run the tests of a repository in a fresh sandbox. In this example, the Durable Object clones the repository, installs its dependencies, runs its test script, returns the result, and stops the instance. 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. Run the test suite Create a Dockerfile in the project root with Git and the runtime your tests need. This one adds Git to Debian Trixie with Node.js 24: FROM node:24-trixie-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends ca-certificates git \ && rm -rf /var/lib/apt/lists/* CMD ["sleep", "infinity"] sleep infinity keeps the container running so that it can accept more exec() calls. In wrangler.jsonc, build the Dockerfile as a named image in your containers entry: { "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "tests": { "dockerfile": "./Dockerfile", }, }, }, ], } Add a method to your Durable Object that clones a repository, installs its dependencies, and runs its tests: export class MyContainer extends DurableObject { // ... async runTests(repository: string, revision: string) { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } container.start({ image: container.images.tests, // `git` and `npm` need the Internet. Do not pass credentials into this sandbox enableInternet: true, }); const decoder = new TextDecoder(); const run = async (step: string, argv: string[], cwd?: string) => { // Limit each step to 10 minutes. const process = await container.exec( ["timeout", "--kill-after=5", "600", ...argv], { cwd }, ); const output = await process.output(); return { step, stdout: decoder.decode(output.stdout), stderr: decoder.decode(output.stderr), exitCode: output.exitCode, }; }; try { const clone = await run("clone", [ "git", "clone", "--depth=1", `--branch=${revision}`, "--", repository, "/workspace", ]); if (clone.exitCode !== 0) { return clone; } const install = await run( "install", ["npm", "ci", "--no-audit", "--no-fund"], "/workspace", ); if (install.exitCode !== 0) { return install; } return await run("test", ["npm", "test", "--", "--silent"], "/workspace"); } finally { if (container.running) { await container.destroy(); } } } } -- stops git from reading a repository value that starts with - as an option. A step that runs longer than 10 minutes returns exit code 124, or 137 if timeout also sends SIGKILL. timeout stops the step together with the processes it starts that stay in its process group. For more information, refer to Stop the processes a command starts. The finally block stops the instance after the run. output() holds the whole output of a step in the memory of the Durable Object. For test suites that print a lot, stream the output instead. enableInternet: true lets git and npm download the repository and its packages. It also lets the install and test scripts of the repository reach the Internet, so do not pass credentials into this sandbox. For more information, refer to Sandbox security. Add a route to your Worker that runs the tests in a new sandbox: export default { async fetch(request: Request, env: Env): Promise { if (request.method !== "POST") { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(crypto.randomUUID()); const result = await sandbox.runTests( "https://github.com/cloudflare/workers-oauth-provider.git", "v0.0.6", ); return Response.json(result, { status: result.exitCode === 0 ? 200 : 500, }); }, } satisfies ExportedHandler; A random sandbox name gives each run a new instance, so one run cannot see files from another. In this example, the Worker runs the tests of version v0.0.6 of the public workers-oauth-provider repository. Replace the repository, revision, and npm commands with your own. exec() does not start a shell, so keep each argument in its own array entry. Deploy your Worker: Send a POST request to the workers.dev URL that Wrangler prints: curl https://..workers.dev --request POST The response includes the test output and exit code: { "step": "test", "stdout": "...\n Test Files 1 passed (1)\n Tests 37 passed (37)\n...", "stderr": "", "exitCode": 0 } When a step fails, the Worker responds with 500, and step names the step that failed. Related resources Stream command output: show test output while the tests run. Save and restore a sandbox with snapshots: keep installed dependencies between runs. Handle outbound traffic: allow only the Git server and package registry instead of the whole Internet. Clone a private repository: clone with a token that stays in your Worker. Stream command output import { PackageManagers, Steps, TypeScriptExample } from "~/components"; Show the output of a command in a browser while the command runs. The Durable Object sends standard output and standard error as server-sent events, reports the exit code, and signals the command to stop when the client disconnects. 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. Stream a command Add a method to your Durable Object that runs a command and returns its output as an event stream: export class MyContainer extends DurableObject { // ... async stream(argv: string[]): Promise { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } if (!container.running) { container.start({ image: "cloudflare/debian-trixie", entrypoint: ["sleep", "infinity"], enableInternet: false, }); } const process = await container.exec(argv); const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder(); const send = (event: string, data: unknown) => writer.write( encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), ); const forward = async ( stream: ReadableStream, event: "stdout" | "stderr", ) => { for await (const text of stream.pipeThrough(new TextDecoderStream())) { await send(event, text); } }; let exited = false; const markExited = () => { exited = true; }; process.exitCode.then(markExited, markExited); // Signaling a process that has exited records an internal error. const stop = () => { if (!exited) process.kill(); }; // Signal the process when the client disconnects. writer.closed.catch(stop); // Detect a disconnected client while the process writes nothing. const heartbeat = setInterval(() => { writer.write(encoder.encode(": keep-alive\n\n")).catch(() => {}); }, 5_000); const pump = async () => { try { await Promise.all([ forward(process.stdout!, "stdout"), forward(process.stderr!, "stderr"), ]); await send("exit", { exitCode: await process.exitCode }); await writer.close(); } catch (error) { stop(); // End the response, so a connected client does not wait forever. await writer.abort(error).catch(() => {}); } finally { clearInterval(heartbeat); } }; // Keep streaming after this method returns the response. void pump(); return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }, }); } } The method returns the response before the command finishes. pump() then writes each chunk as the process produces it. pump() reads standard output and standard error at the same time, because a stream that nobody reads can block a process that keeps writing to it. TextDecoderStream keeps characters that span two chunks intact. When the client disconnects, the next write fails and the method calls kill(), which sends SIGTERM to the command. A command can ignore SIGTERM; to enforce a limit, refer to Stop a command after a time limit. The keep-alive comment every five seconds makes that write happen while the command is silent. Add a route to your Worker that streams a command your application chooses: const build = [ "sh", "-c", 'for i in 1 2 3; do echo "step $i"; sleep 1; done; echo "warning: almost done" >&2; exit 3', ]; export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/build$/.exec(url.pathname); if (!match || request.method !== "GET") { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(match[1]); return sandbox.stream(build); }, } satisfies ExportedHandler; The example command writes a line each second, writes a warning to standard error, and exits with code 3. Replace it with your build or test command. The Worker chooses the command instead of reading one from the request. For more information, refer to Sandbox security. Deploy your Worker: Stream the command in the sandbox named ada. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/sandboxes/ada/build --no-buffer The events arrive about one second apart: event: stdout data: "step 1\n" event: stdout data: "step 2\n" event: stdout data: "step 3\n" event: stderr data: "warning: almost done\n" event: exit data: {"exitCode":3} Read the stream in a browser Each event has a type and a JSON-encoded data value. A stdout or stderr event can hold part of a line or several lines, so append the text instead of treating each event as a line. The exit event comes last. Lines that start with : are comments, which clients ignore. The route answers GET requests, so a page can read it with EventSource: const output = document.querySelector("pre"); const events = new EventSource("/sandboxes/ada/build"); for (const type of ["stdout", "stderr"]) { events.addEventListener(type, (event) => { output.textContent += JSON.parse(event.data); }); } events.addEventListener("exit", (event) => { output.textContent += `\nExit code ${JSON.parse(event.data).exitCode}\n`; events.close(); }); EventSource reconnects when a stream ends, which would run the command again. The page calls close() on the exit event to prevent that. Stop a command after a time limit A disconnect signals only the process that exec() started. Processes that the command starts, such as the commands in an sh -c script, keep the output streams open until they exit. To stop a command and the processes it starts after a time limit, run it under GNU coreutils timeout: const build = ["timeout", "--kill-after=5", "60", "sh", "-c", "npm test"]; The command exits with code 124 when it runs longer than 60 seconds, or 137 if timeout also sends SIGKILL. A process that moves to its own process group, for example with setsid, keeps running. For more information, refer to Stop the processes a command starts. Related resources Execute commands: combine standard error with standard output, send standard input, and handle exit codes. Run background processes: commands that outlive the request. exec() Choose a sandbox environment import { CardGrid, LinkTitleCard } from " /components"; import { SandboxEnvironments } from "/components/react/sandbox/SandboxEnvironments"; A sandbox runs code that you did not write and do not trust, such as code that a model generates or the test suite of a repository. Cloudflare runs it in one of two environments: a Container, which runs a Linux image, or a Dynamic Worker, which runs JavaScript against methods that your Worker provides. Your Worker starts either one, and one Worker can use both. What the code can use Code in a Container can use everything in its Linux image: runtimes, packages, files, and the processes it starts. Code in a Dynamic Worker can use the Workers runtime APIs, plus the modules, methods, and values that your Worker passes to it. It does not inherit the bindings, credentials, or data of your Worker. It can reach the Internet unless you set globalOutbound to null. Your Worker keeps credentials and policy. It starts a Container, which runs a command in your Linux image and can use runtimes, packages, files, and processes. It also passes methods and values to a Dynamic Worker, which runs generated code and does not inherit your bindings, data, or credentials. Use a Container when the code expects Linux Most existing tools expect an operating system as well as a language runtime. npm test needs Node.js, dependencies on a filesystem, and child processes. A preview server keeps a process running and listens on a port. A compiler may need a toolchain or native binaries. JavaScript code can need Linux too. A Dynamic Worker cannot start child processes or load native add-ons, and Workers implements some Node.js modules only in part. For what Workers supports, refer to Node.js compatibility. Your Worker decides when the container starts, whether it can reach the Internet, and which HTTP requests reach it. The Agents sandbox tool uses a Container to run commands for an agent. Use a Dynamic Worker when the code calls known methods When code only needs to call methods that you define, such as a script against a known API, run it in a Dynamic Worker. Your Worker decides what each method can reach before it passes the method in. For example, your Worker can pass a listPullRequests() method that works for one repository and keeps the API token. Generated code can filter the pull requests, but it cannot reach another repository or read the token. For more information, refer to Bindings. Code Mode uses this for agents. The model writes JavaScript against typed methods, a Dynamic Worker runs it, and only the result returns to the model context. load() creates a new Dynamic Worker. get() with a stable ID lets the runtime reuse a warm Dynamic Worker when the same code runs again. To give a generated application its own storage, use Durable Object facets. That storage does not give the application access to your other resources. How each environment isolates code A Container runs in a Firecracker microVM with its own kernel and network, which no other workload shares. Your image runs as a Linux container inside that VM. V8, the JavaScript engine in Chrome, runs each Dynamic Worker in a sandbox that cannot read memory outside itself. The Workers runtime adds a process-level sandbox around it. For more information, refer to the Workers security model. What you maintain With a Container, you build and update the image. Existing tools run without changes, but the container must start before the first command runs. For more information, refer to Container cold starts. With a Dynamic Worker, you write each method and decide what it returns. When generated code needs another operation, you add a method. The code that loads the Dynamic Worker lists everything the generated code can reach. Use both in one application A coding agent can answer questions about a repository through scoped methods in a Dynamic Worker. When the agent proposes a patch, the same Worker starts a Container and runs the test suite of the repository. { private readonly container: Container; private readonly backups: DirectoryBackup; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("No container is configured"); } this.container = container; this.backups = new DirectoryBackup( container, ctx.exports.DirectoryBackupGateway, { binding: "BACKUPS", prefix: "backups/" }, ); // A restarted Durable Object sets the timeout again. if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } private async startSandbox(): Promise { if (!this.container.running) { this.container.start({ image: this.container.images.workspace, enableInternet: false, }); await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } } } The container sends the backup through DirectoryBackupGateway, which writes it to the BACKUPS bucket. The container never receives R2 credentials, and it can reach only the object of the operation in progress. The constructor creates one DirectoryBackup object for the Durable Object, because this.ctx.container stays the same object for as long as the Durable Object runs. Add a method to MyContainer that adds a line to a file in the project and returns the file, so you can check what a restore brings back: export class MyContainer extends DurableObject { // ... async note(line?: string) { await this.startSandbox(); const script = line ? `mkdir -p ${project} && printf "%s\n" "$1" >> ${project}/notes.txt && cat ${project}/notes.txt` : `cat ${project}/notes.txt`; const process = await this.container.exec(["sh", "-c", script, "sh", line ?? ""]); const output = await process.output(); return new TextDecoder().decode(output.stdout); } } Add a method to MyContainer that backs up the project and stores the backup record: export class MyContainer extends DurableObject { // ... async backup() { await this.startSandbox(); const backup = await this.backups.backup({ dir: project }); await this.ctx.storage.put(`backup:${backup.id}`, backup); return { id: backup.id, size: backup.size }; } } backup() returns a record with the ID, size, and SHA-256 of the object. The package keeps no list of backups. The Durable Object stores the record and looks it up by ID, so a request cannot point a restore at another object in the bucket. Stop the commands that write to the directory before you call backup(). A file that changes during the backup can be saved partly old and partly new. Add a method to MyContainer that looks up a record and restores it: export class MyContainer extends DurableObject { // ... async restore(id: string) { const backup = await this.ctx.storage.get( `backup:${id}`, ); if (!backup) { return false; } await this.startSandbox(); await this.backups.restore(backup); return true; } } restore() replaces the directory and removes files that are not in the backup. Before it replaces anything, it checks the object against the SHA-256 in the record. When restore() throws SandboxFileError or SandboxBackupError, it leaves the directory as it was. If the object no longer exists, restore() throws SandboxBackupError with the code BACKUP_NOT_FOUND. If the object does not match the record, the code is BACKUP_INTEGRITY. For every error, refer to DirectoryBackup errors. Replace the default export of your Worker with routes that write a note, back up the project, and restore a backup: export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(notes|backups)(?:\/([0-9a-f-]{36})\/restore)?$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const [, name, resource, id] = match; const sandbox = env.MY_CONTAINER.getByName(name); if (resource === "notes" && !id) { const line = request.method === "POST" ? await request.text() : undefined; return new Response(await sandbox.note(line)); } if (resource === "backups" && request.method === "POST") { if (!id) { return Response.json(await sandbox.backup(), { status: 201 }); } return (await sandbox.restore(id)) ? new Response(null, { status: 204 }) : new Response("Backup not found", { status: 404 }); } return new Response("Method not allowed", { status: 405 }); }, } satisfies ExportedHandler; A backup holds every file in the directory, including credentials written in the sandbox. Authenticate callers first, so other people cannot back up or restore the files of another sandbox. For more information, refer to Sandbox security. Deploy your Worker: In the sandbox named ada, write a note and back up the project. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/sandboxes/ada/notes --data "Keep this line." curl https://..workers.dev/sandboxes/ada/backups --request POST The backup responds with its ID and its size in bytes, such as {"id":"","size":217}. Write a second note, restore the backup, then read the notes. Replace with the ID from the response: curl https://..workers.dev/sandboxes/ada/notes --data "Lose this line." curl https://..workers.dev/sandboxes/ada/backups//restore --request POST curl https://..workers.dev/sandboxes/ada/notes The restore responds with 204, and the notes are back to the state of the backup: Keep this line. Leave out files To leave out files, pass patterns in .gitignore syntax, relative to the directory. node_modules/ matches a directory with that name at any depth, and /build matches only at the top: const backup = await this.backups.backup({ dir: project, exclude: ["node_modules/", "/build"], // Also apply the `.gitignore` files in the directory and `.git/info/exclude`, // without `git` in the image gitignore: true, }); Delete a backup To delete a backup, delete its object and its record: export class MyContainer extends DurableObject { // ... async deleteBackup(id: string) { const backup = await this.ctx.storage.get( `backup:${id}`, ); if (backup) { // `delete()` does not need a running container await this.backups.delete(backup); await this.ctx.storage.delete(`backup:${id}`); } } } An object lifecycle rule that expires objects under backups/ also deletes backups. The rule leaves their records, and restoring one of those backups throws BACKUP_NOT_FOUND. A backup that fails does not leave an object. If the Durable Object restarts during a backup, the incomplete multipart upload stays in the bucket until the default lifecycle rule of the bucket aborts it after seven days. Related resources DirectoryBackup API: what a backup keeps, one operation at a time per container, cancellation, and every error. Save and restore a sandbox with snapshots: keep the whole filesystem instead of one directory. Mount an R2 bucket: read and write files that other systems use. Work with files in a sandbox The Files class from @cloudflare/sandbox moves files between your Worker and a running sandbox, and provides common file system operations. For more information, refer to Move files in and out of a sandbox. Each sandbox name maps to a Durable Object, which starts a Linux instance when needed. Stopping the instance deletes the files on its disk. For more information, refer to Sandbox lifetime. Persistence You can keep files from a sandbox in several ways, from a snapshot of the root filesystem to one directory kept in R2: Goal Guide Trade-off Continue an agent workspace in a later session Save and restore a sandbox with snapshots Saves the root filesystem, but not mounted directories Keep a workspace without explicit save calls Save a sandbox automatically Same as snapshots, plus an alarm in your Durable Object Keep a project across images, or beyond 30 days Back up a directory to R2 Saves one directory, and your Durable Object stores each backup record Share job inputs and outputs with other systems Mount an R2 bucket Renames, locks, and permissions do not work as they do on a local disk Combine approaches Snapshots do not include mounted directories, so a sandbox can use both. For example, a coding agent can keep its repository and dependencies in a snapshot, and write results to a mounted bucket for other Workers to read. For more information, refer to Sandbox lifetime. Related resources Sandbox lifetime Sandbox security Files API DirectoryBackup API S3Mount API Move files in and out of a sandbox import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; This guide streams an uploaded file into a sandbox, compresses it with gzip, and streams the compressed file back to the caller. The Files class from @cloudflare/sandbox reads and writes files in a running Linux instance, and exec() runs the command. Files does not start the instance, so your Durable Object starts it before each file operation. Prerequisites A Worker project from Run a Linux command. This project uses a Durable Object that starts a container with the Durable Object scheduling policy. Run a command on an uploaded file Install the package: To give Files its helper binary, create a Dockerfile in the project root. If you already have one, add the COPY line to it: FROM node:24-trixie-slim COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim RUN mkdir /workspace CMD ["sleep", "infinity"] The cloudflare/sandbox tag must match the installed package version. A mismatch causes SandboxProtocolError. Replace the FROM line with the base image your commands need. To build the image, add the nodejs_compat flag to wrangler.jsonc and replace the containers entry: { "compatibility_flags": ["nodejs_compat"], "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "workspace": { "dockerfile": "./Dockerfile", }, }, }, ], } The workspace key names the image. Your code starts it as container.images.workspace. To start an instance before each file operation, replace src/index.ts with a MyContainer class that has a startSandbox() method: import { Files } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; const workspace = "/workspace"; const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject { private readonly container: Container; private readonly files: Files; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("No container is configured"); } this.container = container; this.files = new Files(container); // A restarted Durable Object sets the timeout again. if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } // Files does not start an instance, so start one before each operation. private async startSandbox(): Promise { if (!this.container.running) { this.container.start({ image: this.container.images.workspace, enableInternet: false, }); await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } } } The constructor creates one Files object for the Durable Object, because this.ctx.container stays the same object for as long as the Durable Object runs. startSandbox() starts the workspace image when no instance is running, and sets the inactivity timeout after start(). For more information, refer to Sandbox lifetime. To write the upload, run the command, and return the output file, add a compress() method to MyContainer: export class MyContainer extends DurableObject { // ... async compress(upload: ReadableStream): Promise { await this.startSandbox(); const name = crypto.randomUUID(); await this.files.writeFile(name, upload, { cwd: workspace }); const process = await this.container.exec(["gzip", "--force", name], { cwd: workspace, }); const output = await process.output(); if (output.exitCode !== 0) { throw new Error(new TextDecoder().decode(output.stderr)); } return this.files.readFile(`${name}.gz`, { cwd: workspace }); } } writeFile() streams the upload into the file, and readFile() returns a Response that streams the output file back. Neither method holds a whole file in the memory of the Durable Object. output() holds only what gzip prints, and that output counts toward the memory limit. A Linux failure, such as a full disk, throws SandboxFileError. Its code holds the Linux error name, such as ENOSPC. To handle other errors, refer to Files errors. To send uploads to compress(), add a default export to src/index.ts: export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/compress$/.exec(url.pathname); if (!match || request.method !== "POST" || !request.body) { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(match[1]); return sandbox.compress(request.body); }, } satisfies ExportedHandler; :::caution This route reads the sandbox name from the URL, so any caller can reach any sandbox. In production, derive the name from the authenticated caller. For more information, refer to Sandbox security. ::: Deploy your Worker: To test the Worker, upload a file to the sandbox named ada and save the response. Replace the hostname with the workers.dev URL that Wrangler prints: printf 'Ada Lovelace\nGrace Hopper\n' | curl \ https://..workers.dev/sandboxes/ada/compress \ --data-binary @- \ --fail-with-body \ --output names.txt.gz Decompress the response: gunzip --stdout names.txt.gz Ada Lovelace Grace Hopper List, rename, and remove files Files has methods for other file operations. Each method accepts the same cwd option as writeFile() and readFile(): Method Use it to readDirectory() List directory entries stat() Read the type, size, owner, and timestamps of a path mkdir() Create a directory rename() Rename or move a file or directory remove() Delete a file, or a directory with recursive: true Compressed files stay in /workspace until the instance stops. To delete a file sooner, call remove() after the caller has read the response. For more information, refer to Files API. Accept paths from callers Some applications let callers choose paths, such as a file browser. Files does not restrict which paths it opens. To keep a request inside one directory, check each path before you pass it to Files. This example adds a download() method that accepts only relative paths without .., and returns 404 for a missing file: import { SandboxFileError } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; const workspace = "/workspace"; // Accept only relative paths that stay inside cwd. function workspacePath(path: string): string | null { if ( path === "" || path.startsWith("/") || path.includes("\0") || path.split("/").includes("..") ) { return null; } return path; } export class MyContainer extends DurableObject { // ... async download(path: string): Promise { const relativePath = workspacePath(path); if (!relativePath) { return new Response("Invalid path", { status: 400 }); } await this.startSandbox(); try { return await this.files.readFile(relativePath, { cwd: workspace }); } catch (error) { if (SandboxFileError.is(error) && error.code === "ENOENT") { return new Response("Not found", { status: 404 }); } throw error; } } } :::caution The check applies only to the path in the request. It does not isolate files inside the sandbox. Code in the sandbox can still create a symbolic link in /workspace that points elsewhere in the sandbox. The user option, such as user: "1000:1000", does not narrow access either. It sets the owner of files that Files creates. The helper can still open files that the user has no permission for. For more information, refer to Sandbox security. ::: Related resources Files API: every method, including readDirectory(), rename(), and remove(). Save and restore a sandbox with snapshots: keep the files after the instance stops. Mount an R2 bucket: write files that other systems read. Mount an R2 bucket import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; The S3Mount class from @cloudflare/sandbox mounts one prefix of a bucket as a directory with s3fs. Your Worker keeps the R2 credentials and signs each storage request. In this example, a command reads an input file from the mount and writes a digest next to it. 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. An R2 bucket, and an R2 API token with the Object Read & Write permission for that bucket only. The examples use a bucket named sandbox-artifacts. Mount a bucket prefix Install the package: Create a Dockerfile in your project root with FUSE, s3fs, and the sandbox-shim binary that S3Mount uses: FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates fuse3 s3fs \ && rm -rf /var/lib/apt/lists/* COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim CMD ["sleep", "infinity"] Use the cloudflare/sandbox tag that matches the @cloudflare/sandbox version you installed. In wrangler.jsonc, turn on nodejs_compat, add the endpoint and name of the bucket as variables, declare the credentials as secrets, and build the Dockerfile as a named image. Replace with your Cloudflare account ID: { "compatibility_flags": ["nodejs_compat"], "vars": { "S3_ENDPOINT": "https://.r2.cloudflarestorage.com", "S3_BUCKET": "sandbox-artifacts", }, "secrets": { "required": ["S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"], }, "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "artifacts": { "dockerfile": "./Dockerfile", }, }, }, ], } secrets.required adds the secrets to the generated types and makes wrangler deploy fail when they are missing. To mount a bucket from another S3-compatible service, change S3_ENDPOINT and S3_BUCKET, and use the credentials for that service. Generate types, then add the Access Key ID and Secret Access Key from the R2 API token as secrets: Import S3Mount, and export S3Gateway from the main module of your Worker: import { S3Mount } from "@cloudflare/sandbox"; // S3Mount sends storage requests from the sandbox to this entrypoint. export { S3Gateway } from "@cloudflare/sandbox"; s3fs in the sandbox sends each storage request to an address that the container intercepts. The intercept delivers the request to S3Gateway in your Worker. S3Gateway checks that the request stays within the bucket, prefix, and access mode of the mount. Then it signs the request with your credentials and sends it to R2. Add a constructor to your Durable Object. It creates one S3Mount object, and sets the inactivity timeout again when a restarted Durable Object finds the container running: const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject { private readonly container: Container; private readonly mounts: S3Mount; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("No container is configured"); } this.container = container; this.mounts = new S3Mount(container, ctx.exports.S3Gateway); if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } } this.ctx.container stays the same object for as long as the Durable Object runs, so one S3Mount object serves every container that the Durable Object starts. Then add a method that starts the sandbox and mounts the prefix for the job: export class MyContainer extends DurableObject { // ... private async mountArtifacts(job: string) { if (!this.container.running) { this.container.start({ image: this.container.images.artifacts, enableInternet: false, }); await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } await this.mounts.mount({ mountPath: "/mnt/artifacts", source: { type: "s3", endpoint: this.env.S3_ENDPOINT, region: "auto", bucket: this.env.S3_BUCKET, credentials: { type: "static", accessKeyId: this.env.S3_ACCESS_KEY_ID, secretAccessKey: this.env.S3_SECRET_ACCESS_KEY, }, }, keyPrefix: `jobs/${job}`, access: "read-write", }); } } Any process in the sandbox can read, change, or delete every object under the prefix. Mount the narrowest prefix the job needs, and use access: "read-only" when the job only reads. The sandbox receives placeholder credentials, because s3fs requires a key pair. The mount works with enableInternet: false. For short-lived credentials, refer to Credentials. Calling mount() again with the same settings reuses the existing mount. Each new mount adds an outbound intercept, and unmount() does not remove it. An instance supports a limited number of intercepts. Give each job its own sandbox name instead of mounting a new prefix in the same instance. Add a method that runs a command in the mounted directory: export class MyContainer extends DurableObject { // ... async digest(job: string) { await this.mountArtifacts(job); const process = await this.container.exec( // The command reads `input.txt` from R2 through the mount // and writes `input.sha256` next to it ["sh", "-c", "sha256sum input.txt > input.sha256"], { cwd: "/mnt/artifacts" }, ); const output = await process.output(); return { exitCode: output.exitCode, stderr: new TextDecoder().decode(output.stderr), }; } } The mount maps files to objects. Renames, locks, and permissions do not work as they do on a local disk. Write results that other systems read to the mount. Keep working files, such as dependencies and build output, on the sandbox disk. For more information, refer to Mounted file behavior. Add a method that unmounts the prefix and stops the sandbox: async finish() { if (!this.container.running) { return; } await this.mounts.unmount("/mnt/artifacts"); await this.container.destroy(); } Call finish() only after every job that writes to the mount has ended. unmount() denies storage requests before it unmounts, so a process that still writes loses those writes. If a process still uses the directory, unmount() throws SandboxS3MountError with code S3_MOUNT_BUSY and requests stay denied. Stop the process, then call unmount() again. Add routes to your Worker that run a job and finish it: export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/jobs\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/digest)?$/.exec(url.pathname); if (!match) { return new Response("Not found", { status: 404 }); } const [, job, digest] = match; const sandbox = env.MY_CONTAINER.getByName(job); if (digest && request.method === "POST") { return Response.json(await sandbox.digest(job)); } if (!digest && request.method === "DELETE") { await sandbox.finish(); return new Response(null, { status: 204 }); } return new Response("Method not allowed", { status: 405 }); }, } satisfies ExportedHandler; Authenticate callers first, so other people cannot run jobs or read their results. For more information, refer to Sandbox security. Deploy your Worker: Upload an input file for the job named ada, run the job, and read the result from R2. Replace the example hostname with the workers.dev URL that Wrangler prints: echo "artifact input" > input.txt npx wrangler r2 object put sandbox-artifacts/jobs/ada/input.txt --file input.txt --remote curl https://..workers.dev/jobs/ada/digest --request POST npx wrangler r2 object get sandbox-artifacts/jobs/ada/input.sha256 --remote --pipe The job responds with {"exitCode":0,"stderr":""}, and R2 has the digest: input.txt Unmount the prefix and stop the sandbox: curl https://..workers.dev/jobs/ada --request DELETE Develop locally Under wrangler dev, the container runs in Docker on your machine, and S3Gateway runs in your local Worker. Point the mount at an S3-compatible server on your machine instead of R2. In this example, the server is MinIO. FUSE needs Docker in a virtual machine, such as Docker Desktop, or rootless Linux Docker with /dev/fuse. For more information, refer to FUSE support. Start MinIO and create a bucket: docker run --detach --name minio --publish 9000:9000 minio/minio server /data docker exec minio sh -c "mc alias set local http://localhost:9000 minioadmin minioadmin && mc mb local/sandbox-artifacts" Create a .dev.vars file in your project root. Its values override the variables and secrets under wrangler dev: S3_ENDPOINT=http://localhost:9000 S3_ACCESS_KEY_ID=minioadmin S3_SECRET_ACCESS_KEY=minioadmin Start the development server: Upload an input file, run the job, and read the result from MinIO: echo "artifact input" | docker exec --interactive minio mc pipe local/sandbox-artifacts/jobs/ada/input.txt curl http://localhost:8787/jobs/ada/digest --request POST docker exec minio mc cat local/sandbox-artifacts/jobs/ada/input.sha256 Related resources S3Mount API: every option and error. Artifact workspace example: a deployable Worker that mounts a prefix for each sandbox and processes a file in it. Save and restore a sandbox with snapshots: keep files on the sandbox disk between instances. Snapshots do not include mounted directories. S3 API compatibility: the S3 operations that R2 supports. Save a sandbox automatically import { PackageManagers, Steps } from "~/components"; An alarm in the Durable Object saves a Container snapshot of an idle sandbox and stops it. While the sandbox is in use, the alarm also saves checkpoints. The next request starts the sandbox from the last snapshot. :::note[Public beta] Container snapshots are in public beta. Features and behavior may change. ::: 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. Save a sandbox before it stops At the top of src/index.ts, define how often the Durable Object checks the sandbox, how long a sandbox can go without requests, and how often to save a sandbox that is in use: const CHECK_INTERVAL_MS = 60 * 1000; const IDLE_LIMIT_MS = 10 * 60 * 1000; const CHECKPOINT_INTERVAL_MS = 15 * 60 * 1000; const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; The inactivity timeout is longer than the check interval, so the instance keeps running between checks. If the checks stop, the timeout still stops the instance, and the sandbox keeps the files from its last save. In MyContainer, add a constructor. It sets the inactivity timeout again when a restarted Durable Object finds the container running, so the instance does not stop before the next check: constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (container?.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } Then replace exec(). It starts a new instance from the stored snapshot when there is one, schedules the first check, and records activity: busy = 0; async exec(argv: string[]) { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } if (!container.running) { const snapshotId = await this.ctx.storage.get("snapshotId"); container.start({ ...(snapshotId ? { containerSnapshot: { id: snapshotId } } : { image: "cloudflare/debian-trixie" }), entrypoint: ["sleep", "infinity"], enableInternet: false, }); await this.ctx.storage.put("savedAt", Date.now()); } await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); if ((await this.ctx.storage.getAlarm()) === null) { await this.ctx.storage.setAlarm(Date.now() + CHECK_INTERVAL_MS); } this.busy++; await this.ctx.storage.put("lastActivity", Date.now()); try { const process = await container.exec(argv); const output = await process.output(); return { stdout: new TextDecoder().decode(output.stdout), exitCode: output.exitCode, }; } finally { this.busy--; await this.ctx.storage.put("lastActivity", Date.now()); } } savedAt holds when the files were last saved or restored. busy counts the running commands, and the Durable Object does not save or stop a sandbox while a command runs. A request in progress keeps the Durable Object in memory, so busy does not need storage. If the stored snapshot cannot be restored, start() fails. To handle that failure, refer to Save and restore a sandbox with snapshots. Add a method that saves a snapshot, and an alarm handler that saves and stops an idle sandbox and saves a checkpoint of a sandbox in use: async save() { const startedAt = Date.now(); const snapshot = await this.ctx.container!.snapshotContainer({ name: "auto", }); await this.ctx.storage.put({ snapshotId: snapshot.id, savedAt: startedAt }); } async alarm() { const container = this.ctx.container; if (!container?.running) { return; } const lastActivity = (await this.ctx.storage.get("lastActivity")) ?? 0; const savedAt = (await this.ctx.storage.get("savedAt")) ?? 0; const idle = this.busy === 0 && Date.now() - lastActivity > IDLE_LIMIT_MS; const checkpoint = this.busy === 0 && lastActivity > savedAt && Date.now() - savedAt > CHECKPOINT_INTERVAL_MS; if (idle || checkpoint) { try { await this.save(); } catch (error) { // Keep the instance and try again at the next check. console.error("Saving the sandbox failed", error); await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); await this.ctx.storage.setAlarm(Date.now() + CHECK_INTERVAL_MS); return; } } const activeDuringSave = this.busy > 0 || (await this.ctx.storage.get("lastActivity")) !== lastActivity; if (idle && !activeDuringSave) { await container.destroy("idle"); return; } await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); await this.ctx.storage.setAlarm(Date.now() + CHECK_INTERVAL_MS); } A save takes a few seconds, and the instance keeps running commands during it. If a request arrives during the save, the alarm does not stop the instance. A later snapshot saves the files written during the save. If a save fails, the handler keeps the instance running and schedules the next check, which tries again. If a deploy interrupts a save, the runtime runs the alarm again. Checkpoints limit what a sandbox loses when something other than the alarm stops it, such as the inactivity timeout after the checks stop. The sandbox restores the last checkpoint, without the changes made after it. A Durable Object has one alarm. If your class already has an alarm() handler, merge the idle and checkpoint checks into it, and set the alarm to the earliest time that any check needs. For more information, refer to Alarms. A snapshot saves the writable root filesystem. It does not include mounted directories, memory, or running processes. A background process that writes a file during a save can leave part of that file in the snapshot. Each save creates a new snapshot. The Durable Object container API has no method to delete a snapshot. A snapshot expires 30 days after it is created or last restored. To save fewer snapshots, increase CHECKPOINT_INTERVAL_MS or remove checkpoints. Add a method that returns the state of the sandbox: async status() { return { running: this.ctx.container?.running ?? false, snapshotId: (await this.ctx.storage.get("snapshotId")) ?? null, }; } Add routes to your Worker that run a command in a named sandbox and return its state: export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/.exec(url.pathname); if (!match) { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(match[1]); if (request.method === "GET") { return Response.json(await sandbox.status()); } const { argv } = (await request.json()) as { argv: string[] }; return Response.json(await sandbox.exec(argv)); }, }; Snapshots can contain credentials or other sensitive files written in the sandbox. Authenticate callers first, and give each user their own sandbox name. For more information, refer to Sandbox security. Deploy the Worker: Write a file in the sandbox named ada. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/sandboxes/ada --json '{"argv":["sh","-c","mkdir -p /workspace && echo hello > /workspace/notes.txt"]}' Send no requests to ada for 11 minutes, then read its state: curl https://..workers.dev/sandboxes/ada { "running": false, "snapshotId": "" } The sandbox saved a snapshot and stopped. The next request starts a new instance from the snapshot. Read the file: curl https://..workers.dev/sandboxes/ada --json '{"argv":["cat","/workspace/notes.txt"]}' { "stdout": "hello\n", "exitCode": 0 } Related resources Save and restore a sandbox with snapshots: save when your application decides, copy a sandbox, and start a sandbox over. Sandbox lifetime: what a snapshot saves, and what a restored instance starts with. Run code when a sandbox stops snapshotContainer() Save and restore a sandbox with snapshots import { PackageManagers, Steps, TypeScriptExample } from "~/components"; A Durable Object saves the filesystem of a sandbox as a Container snapshot, stores the snapshot ID, and starts the next instance from the snapshot. In this example, the sandbox keeps a notes file in /workspace. :::note[Public beta] Container snapshots are in public beta. Features and behavior may change. ::: 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. Save and restore a sandbox Add an inactivity timeout to your Durable Object. The constructor sets the timeout again when a restarted Durable Object finds the container running: const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (container?.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } } Then start the container from the stored snapshot when there is one: export class MyContainer extends DurableObject { // ... private async startSandbox(): Promise { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } if (!container.running) { const snapshotId = await this.ctx.storage.get("snapshotId"); container.start({ ...(snapshotId ? { containerSnapshot: { id: snapshotId } } : { image: "cloudflare/debian-trixie" }), entrypoint: ["sleep", "infinity"], enableInternet: false, }); await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } return container; } } If the stored snapshot cannot be restored, the request fails with an error such as Snapshot "" was not found. Later requests fail the same way until the stored ID changes. The method does not fall back to the image, because the sandbox would then start with an empty workspace. Add a method that runs one operation at a time, and a method that saves the filesystem and stops the instance: export class MyContainer extends DurableObject { // ... private queue: Promise = Promise.resolve(); // Runs each operation after the previous one finishes. private exclusive(operation: () => Promise): Promise { const result = this.queue.then(operation); this.queue = result.catch(() => {}); return result; } async save() { return this.exclusive(async () => { const container = await this.startSandbox(); const snapshot = await container.snapshotContainer({ name: "workspace" }); await this.ctx.storage.put("snapshotId", snapshot.id); await container.destroy(); return { snapshotId: snapshot.id, size: snapshot.size }; }); } } A command that writes a file while the snapshot runs can succeed and then disappear when destroy() stops the instance. exclusive() makes every operation that touches the disk wait for the save, and the save wait for them. Run each such method through it. A snapshot saves the writable root filesystem. It does not include mounted directories, memory, or running processes. Finish writing files before you save. For more information about what a restored instance starts with, refer to Sandbox lifetime. Add a method that adds a line to a file and returns the file, so you can check what a restore brings back: export class MyContainer extends DurableObject { // ... async note(line?: string) { return this.exclusive(async () => { const container = await this.startSandbox(); const script = line ? 'mkdir -p /workspace && printf "%s\n" "$1" >> /workspace/notes.txt && cat /workspace/notes.txt' : "cat /workspace/notes.txt"; const process = await container.exec(["sh", "-c", script, "sh", line ?? ""]); const output = await process.output(); return new TextDecoder().decode(output.stdout); }); } } Add routes to your Worker that write a note, save the sandbox, and read the notes: export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(notes|snapshot)$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const [, name, action] = match; const sandbox = env.MY_CONTAINER.getByName(name); if (action === "snapshot" && request.method === "POST") { return Response.json(await sandbox.save()); } if (action === "notes" && request.method === "POST") { return new Response(await sandbox.note(await request.text())); } if (action === "notes") { return new Response(await sandbox.note()); } return new Response("Method not allowed", { status: 405 }); }, } satisfies ExportedHandler; Snapshots can contain credentials or other sensitive files written in the sandbox. Authenticate callers first, so other people cannot read or restore the files of another sandbox. For more information, refer to Sandbox security. Deploy your Worker: Write a note in the sandbox named ada, save the sandbox, then read the notes. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/sandboxes/ada/notes --data "Continue from this checkpoint." curl https://..workers.dev/sandboxes/ada/snapshot --request POST curl https://..workers.dev/sandboxes/ada/notes The save responds with the snapshot ID and its size in bytes, such as {"snapshotId":"","size":123456}. The last request starts a new instance from the snapshot, and the note is back: Continue from this checkpoint. Copy a sandbox A snapshot ID restores in any Durable Object of the same class. To copy the files of one sandbox to a new sandbox, store the snapshot ID in the Durable Object of the new sandbox: Add methods to your Durable Object that return the stored snapshot ID and store another one: export class MyContainer extends DurableObject { // ... async snapshotId() { return (await this.ctx.storage.get("snapshotId")) ?? null; } async useSnapshot(snapshotId: string) { return this.exclusive(async () => { // Replacing a running instance would discard files it has not saved. if (this.ctx.container?.running) { return false; } await this.ctx.storage.put("snapshotId", snapshotId); return true; }); } } Add a route to your Worker that copies one sandbox to another: if (action === "copy" && request.method === "POST") { const from = url.searchParams.get("from") ?? ""; const snapshotId = await env.MY_CONTAINER.getByName(from).snapshotId(); if (!snapshotId || !(await sandbox.useSnapshot(snapshotId))) { return new Response("Cannot copy", { status: 409 }); } return new Response(null, { status: 204 }); } Add copy to the actions in the route pattern. A copy receives every file in the snapshot, so copy only between sandboxes that belong to the same user. Copy ada to grace, then read the notes from grace: curl "https://..workers.dev/sandboxes/grace/copy?from=ada" --request POST curl https://..workers.dev/sandboxes/grace/notes Continue from this checkpoint. From this point, each sandbox changes its own files. Start a sandbox over To discard the files of a sandbox, destroy its instance and delete the stored snapshot ID. The next request starts from the image: export class MyContainer extends DurableObject { // ... async reset() { return this.exclusive(async () => { if (this.ctx.container?.running) { await this.ctx.container.destroy(); } await this.ctx.storage.delete("snapshotId"); }); } } Deleting the stored ID does not delete the snapshot, so a sandbox that copied it can still restore it until the snapshot expires. Related resources Save a sandbox automatically: save before a sandbox stops for inactivity, and save checkpoints while it is in use. Back up a directory to R2: keep one directory in your own bucket instead of the whole filesystem. Checkpoint workspace example: a deployable Worker that saves, restores, and copies sandboxes. Sandbox lifetime Durable Object container API Build a coding agent runner import { FileTree, PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; In this tutorial, you will build a Worker that runs a coding agent on a GitHub repository in a Linux sandbox. You send a prompt, the agent edits the repository inside the sandbox, and you read its changes as a diff. The runner uses Claude Code. The pages in Coding agents switch it to other agents. Claude Code calls Anthropic models through AI Gateway. Your Worker adds the gateway token to those requests, so the token never enters the sandbox. The sandbox can reach only your gateway and github.com. When you finish, you can ask the agent to add a file to a repository and read its change: diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..95da11e --- /dev/null +++ b/NOTES.md @@ -0,0 +1 @@ +This repository is a minimal test/example repo containing only a "Hello World!" README file. You will learn how to: Keep model credentials in your Worker while an agent runs in a sandbox. Allow a sandbox to reach only the hostnames a task needs. Run a task that lasts several minutes in the background and keep the sandbox running until it ends. Read the outcome and changes of the agent after the task ends. Prerequisites You also need: An authenticated AI Gateway and a gateway token with the Run permission. Anthropic credentials in AI Gateway, either Unified Billing credits or an Anthropic API key stored as a provider key. Your Cloudflare account ID. 1. Create the project Create a Worker project: Change into the project directory: cd sandbox-coding-agent Install the @cloudflare/sandbox package and Zod: Create a Dockerfile in your project root: FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \ && rm -rf /var/lib/apt/lists/* # The install script from the Claude Code package puts its native binary in place RUN npm install --global @anthropic-ai/claude-code@2.1.308 COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace # Keep the container running between requests CMD ["sleep", "infinity"] Install everything the agent needs in the image, because the sandbox cannot download packages at run time. Pin the Claude Code version. Its command-line flags and event format can change between releases. Use the cloudflare/sandbox tag that matches the @cloudflare/sandbox version you installed. Replace wrangler.jsonc. Replace with your account ID, and default with your gateway ID if it is different: { "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-coding-agent", "main": "src/index.ts", "compatibility_date": "$today", "compatibility_flags": ["nodejs_compat"], "observability": { "enabled": true, }, "upload_source_maps": true, "vars": { "AI_GATEWAY_ACCOUNT_ID": "", "AI_GATEWAY_ID": "default", "MODEL": "claude-sonnet-5", }, "secrets": { "required": ["AI_GATEWAY_TOKEN"], }, "containers": [ { "class_name": "AgentSandbox", "scheduling_policy": "durable_object", "images": { "agent": { "dockerfile": "./Dockerfile", }, }, }, ], "durable_objects": { "bindings": [ { "class_name": "AgentSandbox", "name": "SANDBOX", }, ], }, "exports": { "AgentSandbox": { "type": "durable-object", "storage": "sqlite", }, }, } Each sandbox is an AgentSandbox Durable Object with its own container. Wrangler builds the Dockerfile when you deploy, and the Durable Object starts the built image as this.ctx.container.images.agent. MODEL is an Anthropic model ID that your gateway can serve. secrets.required makes wrangler deploy fail if the gateway token is missing. Generate types for the bindings, variables, and secret: In the next three sections, you create src/outbound.ts and src/sandbox.ts and replace src/index.ts. The tutorial adds or replaces these files: Dockerfile wrangler.jsonc src outbound.ts sandbox.ts index.ts 2. Allow only the gateway and GitHub Create src/outbound.ts. The sandbox sends every HTTP request on port 80 and HTTPS request on port 443 through this entrypoint in your Worker. Without Internet access, the sandbox cannot reach other ports: import { WorkerEntrypoint } from "cloudflare:workers"; const gatewayHost = "gateway.ai.cloudflare.com"; export class Outbound extends WorkerEntrypoint { async fetch(request: Request): Promise { const url = new URL(request.url); const gatewayPath = `/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}`; if (url.protocol !== "https:") { return new Response(`${url.hostname} is reachable only over HTTPS\n`, { status: 403, }); } if ( url.hostname === gatewayHost && (url.pathname === gatewayPath || url.pathname.startsWith(`${gatewayPath}/`)) ) { const headers = new Headers(request.headers); headers.delete("x-api-key"); headers.set( "cf-aig-authorization", `Bearer ${this.env.AI_GATEWAY_TOKEN}`, ); return fetch(new Request(request, { headers })); } if (url.hostname === "github.com") { return fetch(request); } return new Response( `${url.hostname} is not reachable from this sandbox\n`, { status: 403 }, ); } } The entrypoint allows two destinations: Requests to your gateway under your account ID and gateway ID. The Worker removes the placeholder API key that Claude Code sends and adds the gateway token. AI Gateway then calls Anthropic with the credentials it holds. If the placeholder stays, AI Gateway forwards it to Anthropic, and the model request fails. Requests to github.com, so Git can clone public repositories. The entrypoint allows both destinations over HTTPS only. The Worker fetches with the scheme the container used, so a plain HTTP request to the gateway would send the gateway token unencrypted. Every other request gets a 403 response, so Claude Code cannot install packages from npm or fetch web pages while it works. To allow another destination, add it to this entrypoint. Each destination you add is another way for data to leave the sandbox. For more information, refer to Sandbox security. 3. Run Claude Code in the sandbox Create src/sandbox.ts. The AgentSandbox Durable Object clones a repository, runs one Claude Code task at a time, and reports the task state and the repository diff: import { Files, SandboxFileError } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; import { z } from "zod"; const repositoryDirectory = "/workspace/repo"; const taskDirectory = "/workspace/task"; const eventsPath = `${taskDirectory}/stdout.log`; const stderrPath = `${taskDirectory}/stderr.log`; const exitCodePath = `${taskDirectory}/exit-code`; const pidPath = `${taskDirectory}/pid`; const inactivityTimeout = 30 * 60 * 1000; const taskCheckInterval = 60 * 1000; const taskKey = "task"; const caPath = "/etc/cloudflare/certs/cloudflare-containers-ca.crt"; // The CA alone replaces the system trust store. This works because // Outbound intercepts every HTTPS request from the container. const trustEnv = { NODE_EXTRA_CA_CERTS: caPath, GIT_SSL_CAINFO: caPath, CURL_CA_BUNDLE: caPath, SSL_CERT_FILE: caPath, }; // Runs the command in its own process group. Records its process ID when // it starts and its exit code when it ends. const taskScript = `dir=$1; shift setsid sh -c 'echo "$$ $(cat /proc/sys/kernel/random/boot_id)" >"$0/pid"; exec "$@"' \\ "$dir" "$@" >"$dir/stdout.log" 2>"$dir/stderr.log" echo "$?" >"$dir/exit-code.tmp" && mv "$dir/exit-code.tmp" "$dir/exit-code"`; // Succeeds while the process in the file runs and started in this instance. const taskRunningScript = `read -r pid boot <"$1" && [ "$boot" = "$(cat /proc/sys/kernel/random/boot_id)" ] && kill -0 "$pid"`; const ResultEvent = z.object({ type: z.literal("result"), subtype: z.string(), is_error: z.boolean(), result: z.string().optional(), }); export type TaskStatus = | { state: "none" } | { state: "running" } | { state: "lost" } | { state: "succeeded"; result: string } | { state: "failed"; error: string }; export type CommandResult = { exitCode: number; stdout: string; stderr: string; }; export class AgentSandbox extends DurableObject { private readonly container: Container; private readonly files: Files; private setup: Promise | undefined; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("No container is configured"); } this.container = container; this.files = new Files(container); if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(inactivityTimeout), ); } } async cloneRepository( repository: string, ref: string | undefined, ): Promise { await this.startSandbox(); const branch = ref === undefined ? [] : ["--branch", ref]; return this.run( [ "git", "clone", "--depth", "1", ...branch, "--", repository, repositoryDirectory, ], "/workspace", trustEnv, ); } startTask(prompt: string): Promise<"started" | "busy"> { return this.ctx.blockConcurrencyWhile(async () => { await this.startSandbox(); if ((await this.taskStatus()).state === "running") { return "busy"; } await this.files.remove(taskDirectory, { recursive: true, force: true }); await this.files.mkdir(taskDirectory); await this.container.exec( [ "/bin/sh", "-c", taskScript, "agent", taskDirectory, ...this.agentCommand(prompt), ], { cwd: repositoryDirectory, env: { ...trustEnv, ...this.agentEnv() }, stdout: "ignore", stderr: "ignore", }, ); this.ctx.storage.kv.put(taskKey, "started"); await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval); return "started"; }); } async alarm(): Promise { if (!this.container.running) { return; } if ((await this.taskStatus()).state === "running") { await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval); return; } this.ctx.storage.kv.delete(taskKey); } async readTask(): Promise { if (!this.container.running) { return this.ctx.storage.kv.get(taskKey) === undefined ? { state: "none" } : { state: "lost" }; } return this.taskStatus(); } async readDiff(): Promise { await this.startSandbox(); return this.run( ["/bin/sh", "-c", "git add --intent-to-add . && git diff HEAD"], repositoryDirectory, {}, ); } private agentCommand(prompt: string): string[] { return [ "claude", "--print", "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions", "--no-session-persistence", "--model", this.env.MODEL, "--", prompt, ]; } private agentEnv(): Record { return { ANTHROPIC_BASE_URL: `https://gateway.ai.cloudflare.com/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}/anthropic`, ANTHROPIC_API_KEY: "provided-by-worker", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", IS_SANDBOX: "1", }; } private async startSandbox(): Promise { // Set up each new container, and a running container // after this Durable Object restarts. if (this.setup === undefined || !this.container.running) { this.setup = this.setUpSandbox().catch((error) => { this.setup = undefined; throw error; }); } await this.setup; } private async setUpSandbox(): Promise { if (!this.container.running) { this.ctx.storage.kv.delete(taskKey); this.container.start({ image: this.container.images.agent, instance: "standard-1", enableInternet: false, }); } try { await this.container.interceptAllOutboundHttp(this.ctx.exports.Outbound); await this.container.interceptOutboundHttps( "*", this.ctx.exports.Outbound, ); await this.container.setInactivityTimeout(inactivityTimeout); } catch (error) { // The next request starts a new container. await this.container.destroy(); throw error; } } private async taskStatus(): Promise { const exitCode = await this.readOptionalText(exitCodePath); if (exitCode !== undefined) { return this.outcome(Number.parseInt(exitCode, 10)); } const pid = await this.readOptionalText(pidPath); if (pid === undefined) { // The task writes its process ID right after it starts. return this.ctx.storage.kv.get(taskKey) === undefined ? { state: "none" } : { state: "running" }; } const probe = await this.run( ["/bin/sh", "-c", taskRunningScript, "probe", pidPath], "/", {}, ); if (probe.exitCode === 0) { return { state: "running" }; } // The task can finish after the first read, so read the exit code // again. const lateExitCode = await this.readOptionalText(exitCodePath); if (lateExitCode !== undefined) { return this.outcome(Number.parseInt(lateExitCode, 10)); } return { state: "lost" }; } private async outcome(exitCode: number): Promise { let result: z.infer | undefined; const events = await this.files.readFile(eventsPath); for await (const line of readLines(events.body)) { const parsed = ResultEvent.safeParse(parseJson(line)); if (parsed.success) { result = parsed.data; } } if (result === undefined) { const stderr = (await this.readOptionalText(stderrPath)) ?? ""; return { state: "failed", error: `claude exited with ${exitCode}: ${stderr.slice(-2000)}`, }; } if (result.is_error) { return { state: "failed", error: result.result ?? result.subtype }; } return { state: "succeeded", result: result.result ?? "" }; } private async readOptionalText(path: string): Promise { try { return await (await this.files.readFile(path)).text(); } catch (error) { if (SandboxFileError.is(error) && error.code === "ENOENT") { return undefined; } throw error; } } private async run( command: string[], cwd: string, env: Record, ): Promise { const process = await this.container.exec(command, { cwd, env }); const output = await process.output(); const decoder = new TextDecoder(); return { exitCode: output.exitCode, stdout: decoder.decode(output.stdout), stderr: decoder.decode(output.stderr), }; } } function parseJson(line: string): unknown { try { return JSON.parse(line); } catch { return undefined; } } async function* readLines( body: ReadableStream | null, ): AsyncGenerator { if (body === null) { return; } const decoder = new TextDecoder(); let buffered = ""; for await (const chunk of body) { buffered += decoder.decode(chunk, { stream: true }); const lines = buffered.split("\n"); buffered = lines.pop() ?? ""; yield* lines; } buffered += decoder.decode(); if (buffered !== "") { yield buffered; } } Only ResultEvent, agentCommand(), agentEnv(), and outcome() are specific to Claude Code. The other pages in Coding agents replace those parts to run a different agent in the same Worker. Start the sandbox startSandbox() starts the container from the image Wrangler built, with no direct Internet access. In this example, the container uses the standard-1 instance type. Choose a size that fits your agent and your repositories. After each start, startSandbox() routes HTTP and HTTPS requests from the container on ports 80 and 443 to the Outbound entrypoint. The interception ends with the container, so startSandbox() registers it on every start. For more information, refer to interceptOutboundHttps(). running is true as soon as start() returns, before the interception is registered. A request that arrives during setup waits for the same setUpSandbox() call, so no command runs before the interception is in place. If a setup step fails, setUpSandbox() stops the container, and the next request starts a new one. A deploy restarts the Durable Object and can stop setUpSandbox() after start(). The restarted Durable Object runs setUpSandbox() again for the container that is already running. Registering an interception again replaces the earlier one, so the container gets its interception before the next command. Containers re-signs HTTPS traffic from the container with a Cloudflare certificate authority, so the Outbound entrypoint can read it. The trustEnv variables point Git, Node.js, and other tools at that certificate. The Durable Object passes them to every command that makes HTTPS requests. SSL_CERT_FILE, CURL_CA_BUNDLE, and GIT_SSL_CAINFO replace the system trust store with that one certificate, which works only because the Outbound entrypoint receives every HTTPS request. If you copy them to a class that intercepts some hostnames only, add the certificate to the system trust store instead. For more information, refer to Trust the CA certificate. Run Claude Code in the background A Claude Code task can run for several minutes, longer than a request should wait. startTask() starts the task and returns. The client then checks the task state with later requests. Claude Code runs without its own permission prompts, because the sandbox limits what its commands can reach. The command sets these options: --print runs Claude Code without an interactive terminal. --output-format stream-json --verbose writes one JSON event per line, including a final result event. --dangerously-skip-permissions lets Claude Code run commands and edit files without asking. Claude Code requires IS_SANDBOX=1 to allow this as root. ANTHROPIC_BASE_URL sends model requests to the Anthropic endpoint of your gateway. ANTHROPIC_API_KEY is a placeholder. Claude Code does not start without an API key, and the Outbound entrypoint removes the key from each request. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC turns off update checks, telemetry, and error reporting, so Claude Code calls only your gateway. If the repository has a CLAUDE.md file, Claude Code reads it, so the instructions in that file apply to the task. The process writes its output to files instead of sending it back to the Durable Object. A process with piped output receives SIGPIPE after the request that started it ends, which stops Claude Code in the middle of its task. taskScript is the RUN script from Run background processes. setsid starts Claude Code in its own process group, and the script records the process ID of Claude Code when it starts. When Claude Code exits, the script writes the exit code to a temporary file and then renames it, so the Durable Object never reads a partial exit code. The task files live in /workspace/task, outside the repository, so they do not appear in the diff. When two requests start a task at the same time, blockConcurrencyWhile() runs them one after the other. The second request sees the running task and returns busy. Keep the sandbox running A running process does not keep the container running, so startTask() schedules an alarm that checks the task every minute until it ends. Each check keeps the container running. For more information, refer to Sandbox lifetime. Read the outcome The task has one of these states: running while the Claude Code process exists. succeeded with the final reply from Claude Code. failed with an error message. lost when the process or its container stopped before Claude Code recorded an exit code. none when no task has run in this container. Claude Code exits successfully even when a model request fails. The outcome comes from the is_error field of the final result event instead of the exit code. The events file comes from the sandbox, so the Durable Object validates each event with Zod before it reads it. When there is no result event, the task fails with the end of the standard error output from Claude Code. While no exit code exists, the Durable Object runs kill -0 to check whether the process still exists. The command runs through sh, because kill is a shell built-in and the slim image has no separate kill binary. If the process is gone, the Durable Object reads the exit code again, because the task can finish between the two reads. Only a process that ended without an exit code is lost. The process ID file also records the boot ID, which changes in every instance. If you restore the workspace from a snapshot, the file comes back, and the new instance reuses the same process IDs. The check ignores a process ID from another instance, so a task that was running when the snapshot was taken is lost. readDiff() marks new files with git add --intent-to-add so that the diff includes them. git diff HEAD compares the files with the checked-out commit, so the diff also includes changes that the agent staged. 4. Route requests Replace src/index.ts with the following Worker. It exports both classes and maps each route to a method on the sandbox named in the URL: import { z } from "zod"; export { Outbound } from "./outbound"; export { AgentSandbox } from "./sandbox"; const sandboxName = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; // Linux rejects a command argument of 128 KiB or more, including its NUL byte. const maxPromptBytes = 128 * 1024 - 1; const RepositoryRequest = z.object({ url: z.url({ protocol: /^https$/, hostname: /^github\.com$/ }), ref: z.string().min(1).optional(), }); export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const match = /^\/sandboxes\/([^/]+)\/(repository|task|diff)$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const [, name, resource] = match; if (!sandboxName.test(name)) { return new Response( "The sandbox name must be 1-63 lowercase letters, digits, or hyphens, with no hyphen at either end", { status: 400 }, ); } const sandbox = env.SANDBOX.getByName(name); try { if (resource === "repository" && request.method === "POST") { const body = RepositoryRequest.safeParse( await request.json().catch(() => null), ); if (!body.success) { return new Response( 'Send {"url": "https://github.com/OWNER/REPOSITORY"}', { status: 400 }, ); } const result = await sandbox.cloneRepository( body.data.url, body.data.ref, ); return Response.json(result, { status: result.exitCode === 0 ? 200 : 502, }); } if (resource === "task" && request.method === "POST") { const prompt = await request.text(); if (prompt.trim() === "") { return new Response("Send a prompt", { status: 400 }); } if (new TextEncoder().encode(prompt).byteLength > maxPromptBytes) { return new Response("Send a prompt smaller than 128 KiB", { status: 413, }); } if ((await sandbox.startTask(prompt)) === "busy") { return new Response("A task is already running", { status: 409 }); } return Response.json({ state: "running" }, { status: 202 }); } if (resource === "task" && request.method === "GET") { return Response.json(await sandbox.readTask()); } if (resource === "diff" && request.method === "GET") { const result = await sandbox.readDiff(); if (result.exitCode !== 0) { return Response.json(result, { status: 502 }); } return new Response(result.stdout, { headers: { "content-type": "text/plain" }, }); } return new Response("Method not allowed", { status: 405 }); } catch (error) { console.error("Sandbox request failed", error); return new Response("Sandbox request failed", { status: 500 }); } }, } satisfies ExportedHandler; The Worker accepts only https://github.com/ repository URLs, because the Outbound entrypoint blocks every other Git server. The routes are: POST /sandboxes//repository clones a repository into the sandbox. POST /sandboxes//task starts Claude Code with the request body as its prompt. The prompt becomes one argument of the claude command, so a prompt of 128 KiB or more gets a 413 response. GET /sandboxes//task returns the task state. GET /sandboxes//diff returns the changes in the repository. 5. Deploy :::caution The Worker does not authenticate requests. Anyone with the URL can run Claude Code and spend your AI Gateway credits. Authenticate callers before you share the URL. For more information, refer to Sandbox security. ::: Create a file named .secrets.json with your gateway token: { "AI_GATEWAY_TOKEN": "" } Deploy your Worker with the secret: Wrangler builds the image with Docker, pushes it to your account, and uploads the Worker with the AI_GATEWAY_TOKEN secret. Delete the secrets file: rm .secrets.json Save the workers.dev URL that Wrangler prints in a shell variable: WORKER_URL=https://sandbox-coding-agent..workers.dev 6. Run a task Clone GitHub's Hello-World repository into a sandbox named agent-1: curl "$WORKER_URL/sandboxes/agent-1/repository" \ --json '{"url": "https://github.com/octocat/Hello-World"}' The first request starts the container, so it can take about a minute. Git writes its progress to standard error: { "exitCode": 0, "stdout": "", "stderr": "Cloning into '/workspace/repo'...\n" } Start a task: curl "$WORKER_URL/sandboxes/agent-1/task" \ --data "Add a file NOTES.md with a one-sentence summary of this repository. Do not commit." The Worker responds with 202 and the task state: { "state": "running" } Check the task until its state is succeeded or failed: curl "$WORKER_URL/sandboxes/agent-1/task" A finished task includes the final reply from Claude Code: { "state": "succeeded", "result": "Created `NOTES.md` with a one-sentence summary. Left uncommitted as requested." } The reply differs on each run. This task takes a few seconds, and larger tasks take minutes. The alarm keeps the sandbox running until Claude Code exits, so you do not need to keep checking. Read the changes: curl "$WORKER_URL/sandboxes/agent-1/diff" The response is a git diff of the repository. The new file appears because readDiff() marks it with --intent-to-add: diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..95da11e --- /dev/null +++ b/NOTES.md @@ -0,0 +1 @@ +This repository is a minimal test/example repo containing only a "Hello World!" README file. If the model request fails, the task state is failed and error contains the message from AI Gateway: Failed to authenticate. API Error: 401 Unauthorized means the gateway rejected the token. Check that the token belongs to the account and gateway in wrangler.jsonc. Claude Code retries a failing model request 10 times, so this error takes about three minutes to appear. API Error: 402 Insufficient wholesale credits means the gateway uses Unified Billing and the account has no credits. Add credits, or store an Anthropic API key in the gateway. Next steps Run another agent in the same Worker. Refer to Codex, OpenCode, or Pi. Deploy a finished runner for each agent. Refer to the coding agents example, which deploys one Worker for each agent instead of switching one Worker. Keep the files of a sandbox after its container stops. Refer to Save and restore a sandbox with snapshots. Clone private repositories with a token that stays in your Worker. Refer to Clone a private repository. Decide what one sandbox should hold. Refer to Sandbox security. Route Claude Code through AI Gateway from other environments. Refer to Claude Code in AI Gateway. Build an AI code interpreter import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; In this tutorial, you will build a Worker that answers questions by running code. A Workers AI model writes JavaScript, a Dynamic Worker runs it without network access, and the model uses the result in its answer. When you finish, the Worker answers a question about prime numbers and returns the code the model ran: { "answer": "There are 9,592 prime numbers below 100,000.", "runs": [ { "code": "function isPrime(n){if(n<2)return false; if(n%2===0) return n===2; for(let i=3;i*i<=n;i+=2){if(n%i===0) return false;} return true;} let count=0; for(let i=2;i<100000;i++){ if(isPrime(i)) count++; } return count;", "output": { "result": 9592, "logs": [] } } ] } You will learn how to: Run model-generated JavaScript in a Dynamic Worker with no network access and a CPU time limit. Give a model a tool that runs code and returns the result. Send code errors back to the model so it can fix its code. Prerequisites 1. Create the project Create a Worker project: Change into the project directory: cd sandbox-code-interpreter Install the Agents SDK, the AI SDK, and Zod. The Worker calls Workers AI with createAI from agents/models/ai-sdk: Replace wrangler.jsonc to add a Workers AI binding and a Worker Loader binding: { "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-code-interpreter", "main": "src/index.ts", "compatibility_date": "$today", "observability": { "enabled": true, }, "upload_source_maps": true, "ai": { "binding": "AI", }, "worker_loaders": [ { "binding": "LOADER", }, ], } AI calls Workers AI models. LOADER creates Dynamic Workers at runtime. Generate types for the bindings: 2. Run code in a sandbox Create src/sandbox.ts. The runJavaScript() function loads the code into a new Dynamic Worker and returns its result and logs. It throws if the code does not finish within five seconds: import type { WorkerEntrypoint } from "cloudflare:workers"; export type Output = { result?: unknown; logs: string[]; }; type CodeEntrypoint = WorkerEntrypoint & { run(): Promise; }; const timeoutMs = 5_000; export async function runJavaScript(env: Env, code: string): Promise { const sandbox = env.LOADER.load({ compatibilityDate: "$today", mainModule: "code.js", modules: { "code.js": ` import { WorkerEntrypoint } from "cloudflare:workers"; function format(value) { return typeof value === "string" ? value : JSON.stringify(value); } export class Code extends WorkerEntrypoint { async run() { const logs = []; const console = { log: (...values) => logs.push(values.map(format).join(" ")), }; const result = await (async () => { ${code} })(); return { result, logs }; } } `, }, globalOutbound: null, limits: { cpuMs: 50 }, }); let timer: number | null = null; const timeout = new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error(`The code did not finish within ${timeoutMs} ms`)); }, timeoutMs); }); try { return await Promise.race([ sandbox.getEntrypoint("Code").run(), timeout, ]); } finally { clearTimeout(timer); } } The Dynamic Worker limits what the generated code can do: globalOutbound: null makes fetch() and connect() throw, so the code has no network access. The Dynamic Worker receives no env, so the code cannot call Workers AI or any other resource your Worker can reach. limits: { cpuMs: 50 } stops code that uses more than 50 milliseconds of CPU time. Refer to Custom resource limits. If the code waits without using CPU, such as in a long setTimeout(), the five-second timeout stops your Worker from waiting for it. The timer runs in your Worker, so the generated code cannot change it. Each call to load() creates a new Dynamic Worker, so values from one run are not available in the next. The generated code becomes the body of an async function, so it can use await and must return its result. A local console object collects console.log() output. The code can still change anything inside the Dynamic Worker, including the wrapper around it, but it cannot reach anything the Dynamic Worker does not receive. 3. Let the model run code Replace src/index.ts. The Worker gives the model a runJavaScript tool and returns the answer from the model with every piece of code the model ran: import { generateText, isStepCount, tool } from "ai"; import { createAI } from "agents/models/ai-sdk"; import { z } from "zod"; import { runJavaScript, type Output } from "./sandbox"; const model = "@cf/openai/gpt-oss-120b"; const Question = z.object({ question: z.string().min(1) }); const instructions = `Answer the question. Always call the runJavaScript tool before you answer. Do not calculate, count, or sort in your head. Write the body of an async JavaScript function and return the value you need. If the tool returns an error, fix the code and run it again. Answer in one or two plain-text sentences.`; type Run = { code: string } & ({ output: Output } | { error: string }); export default { async fetch(request: Request, env: Env): Promise { if (request.method !== "POST") { return new Response("Send a POST request", { status: 405 }); } const body = Question.safeParse(await request.json().catch(() => null)); if (!body.success) { return Response.json( { error: "Send a JSON body with a question" }, { status: 400 }, ); } const ai = createAI({ binding: env.AI }); const runs: Run[] = []; try { const result = await generateText({ model: ai(model), instructions, prompt: body.data.question, tools: { runJavaScript: tool({ description: "Run JavaScript in a sandbox with no network access. The code is the body of an async function. Return a JSON-serializable value. console.log output is captured.", inputSchema: z.object({ code: z.string().describe("Body of an async JavaScript function"), }), execute: async ({ code }) => { try { const output = await runJavaScript(env, code); runs.push({ code, output }); return output; } catch (error) { const message = error instanceof Error ? error.message : String(error); runs.push({ code, error: message }); return { error: message }; } }, }), }, // Stop after five model steps, so a model that keeps failing cannot // run code indefinitely stopWhen: isStepCount(5), }); return Response.json({ answer: result.text, runs }); } catch (error) { console.error("Model request failed", error); return Response.json( { error: "The model request failed. Try again later.", runs }, { status: 502 }, ); } }, } satisfies ExportedHandler; generateText() sends the question to the model. When the model calls runJavaScript, the AI SDK runs execute() and sends the return value back to the model. The model can then run more code or answer. If the code throws, exceeds the CPU time limit or the timeout, or calls fetch(), execute() returns the error message instead of failing the request. The model reads the error and can fix its code. The Worker validates the request body with Zod before it calls the model. If a Workers AI request fails, for example because of a rate limit, the Worker logs the error and responds with 502 and the code that already ran. observability in wrangler.jsonc keeps those logs in Workers Logs. 4. Test locally Start a local development server: During wrangler dev, the Dynamic Worker runs locally. Workers AI requests always run on Cloudflare and count toward your Workers AI usage. POST a question to the URL Wrangler prints. The default is http://localhost:8787: curl http://localhost:8787 --json '{"question":"How many prime numbers are there below 100,000?"}' The response includes the answer and the code the model ran: { "answer": "There are 9,592 prime numbers below 100,000.", "runs": [ { "code": "function isPrime(n){if(n<2)return false; if(n%2===0) return n===2; for(let i=3;i*i<=n;i+=2){if(n%i===0) return false;} return true;} let count=0; for(let i=2;i<100000;i++){ if(isPrime(i)) count++; } return count;", "output": { "result": 9592, "logs": [] } } ] } The model writes different code and wording each time. result is 9592. Ask a question that needs sorting: curl http://localhost:8787 --json '{"question":"Sort these words by length, then alphabetically: pear, fig, banana, kiwi, apple, date."}' The answer lists fig, date, kiwi, pear, apple, banana. 5. Deploy :::caution The Worker does not authenticate requests. Anyone with the URL can send questions and use your Workers AI allowance. Authenticate callers before you share the URL. For more information, refer to Sandbox security. ::: Deploy your Worker: POST a question to the workers.dev URL Wrangler prints: curl https://sandbox-code-interpreter..workers.dev --json '{"question":"How many prime numbers are there below 100,000?"}' The response has the same shape as the local response. Next steps Let generated code call methods that your Worker provides. Refer to Bindings. Let a model call typed tools from generated code. Refer to Code Mode. Run shell commands or other Linux tools. Refer to Run a Linux command. Choose a different model. Refer to Workers AI models. Run JavaScript import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; You will POST JavaScript to a Worker and read {"result":["Ada"]}. Prerequisites Run JavaScript Create a Worker project: Change into the project directory: cd sandbox-dynamic-worker Replace wrangler.jsonc to add a Worker Loader binding: { "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-dynamic-worker", "main": "src/index.ts", "compatibility_date": "$today", "observability": { "enabled": true, }, "upload_source_maps": true, "worker_loaders": [ { "binding": "LOADER", }, ], } Generate types for the binding: Replace src/index.ts. Your Worker reads code from the JSON body and runs it in the sandbox: import type { WorkerEntrypoint } from "cloudflare:workers"; type CodeEntrypoint = WorkerEntrypoint & { evaluate(): Promise; }; export default { async fetch(request: Request, env: Env): Promise { const { code } = (await request.json()) as { code: string }; const sandbox = env.LOADER.load({ compatibilityDate: "$today", mainModule: "code.js", modules: { "code.js": ` import { WorkerEntrypoint } from "cloudflare:workers"; export class Code extends WorkerEntrypoint { evaluate() { ${code} } } `, }, // Block `fetch()` and `connect()` globalOutbound: null, // Stop code that uses more than 50 milliseconds of CPU time limits: { cpuMs: 50 }, }); const result = await sandbox .getEntrypoint("Code") .evaluate(); return Response.json({ result }); }, }; code is the body of evaluate(), so it needs a return statement. Code that waits without using CPU can still hold the request open, so add a timeout before you run code from other people. For an example, refer to Build an AI code interpreter. Run wrangler dev: POST JavaScript to the URL Wrangler prints. The default is http://localhost:8787: curl http://localhost:8787 --request POST --json '{ "code": "const users = [{ name: \"Ada\", role: \"admin\" }, { name: \"Grace\", role: \"user\" }]; return users.filter((u) => u.role === \"admin\").map((u) => u.name);" }' The response body is {"result":["Ada"]}. The Worker ran the JavaScript you sent. Next steps Let the code call methods that your Worker provides. Refer to Bindings. Run a Linux command. Refer to Run a Linux command. Use the Loader API for load() and get(). Run a Linux command import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; You will POST uname -a to a Worker and read stdout that contains Linux. Prerequisites Run a command in Linux Create a Worker project: Change into the project directory: cd sandbox-linux Replace wrangler.jsonc so a Durable Object can start a container: { "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-linux", "main": "src/index.ts", "compatibility_date": "$today", "observability": { "enabled": true, }, "upload_source_maps": true, "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", }, ], "durable_objects": { "bindings": [ { "class_name": "MyContainer", "name": "MY_CONTAINER", }, ], }, "exports": { "MyContainer": { "type": "durable-object", "storage": "sqlite", }, }, } Replace src/index.ts. The Worker reads argv from the JSON body and runs it in Linux: import { DurableObject } from "cloudflare:workers"; export class MyContainer extends DurableObject { async exec(argv: string[]) { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } if (!container.running) { container.start({ // Debian Trixie with Node.js 24 image: "cloudflare/debian-trixie", // Keep the instance running so it can accept commands entrypoint: ["sleep", "infinity"], // Block commands in the sandbox from reaching the Internet enableInternet: false, }); } const process = await container.exec(argv); const output = await process.output(); return { stdout: new TextDecoder().decode(output.stdout), exitCode: output.exitCode, }; } } export default { async fetch(request: Request, env: Env): Promise { const { argv } = (await request.json()) as { argv: string[] }; const sandbox = env.MY_CONTAINER.getByName("sandbox"); return Response.json(await sandbox.exec(argv)); }, }; Generate types for the binding. Wrangler reads the MyContainer class from src/index.ts to type env.MY_CONTAINER: Run wrangler dev: wrangler dev runs the instance in Docker on your machine, so Docker must be running. Running cloudflare/debian-trixie locally needs Wrangler 4.141.0 or later. POST a command to the URL Wrangler prints. The default is http://localhost:8787: curl http://localhost:8787 --request POST --json '{"argv":["uname","-a"]}' The JSON body includes "exitCode":0. stdout contains Linux. The Worker started a Linux container and ran the command you sent. When you deploy, each instance runs in its own virtual machine instead of in Docker. Next steps Run another process in the instance. Refer to exec(). Deploy your Worker. Refer to Deploy Containers. A deploy does not replace a running instance. Refer to Sandbox lifetime. Run JavaScript. Refer to Run JavaScript. Use the Durable Object container API for start() and exec(). Start a project from the minimal sandbox template. It adds a Dockerfile, a sandbox for each name in the URL, and file reads and writes with @cloudflare/sandbox. Manage sandboxes import { DirectoryListing } from "~/components"; Your Worker reaches each sandbox by name. The runtime does not record which names belong to which user, and it does not wake a Durable Object when its container stops. To list a user's sandboxes or act when a sandbox stops, your Worker keeps its own records. List a user's sandboxes import { PackageManagers, Steps, WranglerConfig } from "~/components"; 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. 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. List the sandboxes In wrangler.jsonc, add a binding for the registry class, and declare the class in exports: { "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", }, }, } If your Worker declares its classes with a migrations array instead, add a migration with a new tag and "new_sqlite_classes": ["SandboxDirectory"]. For more information, refer to Durable Object class migrations (legacy). Add the registry class to your Worker. Each user gets one SandboxDirectory, which stores the user's sandbox sessions in SQLite: export class SandboxDirectory extends DurableObject { 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: const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject { 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 after start(), inside the if (!container.running) block: await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); Then add the two methods to MyContainer: export class MyContainer extends DurableObject { // ... 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 /, record the session before running a command, list the sessions with their state, and remove a session after stopping its container: export default { async fetch(request: Request, env: Env): Promise { 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: false until 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: Start two sandboxes for the user ada, on the workers.dev URL that Wrangler prints: curl https://..workers.dev/users/ada/sandboxes/notes --request POST --json '{"argv":["uname","-s"]}' curl https://..workers.dev/users/ada/sandboxes/tests --request POST --json '{"argv":["uname","-s"]}' List them: curl https://..workers.dev/users/ada/sandboxes [ { "session": "notes", "running": true }, { "session": "tests", "running": true } ] Stop one of them, and list again: curl https://..workers.dev/users/ada/sandboxes/tests --request DELETE curl https://..workers.dev/users/ada/sandboxes [{ "session": "notes", "running": true }] List sandboxes across the account 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//containers/applications//instances?name_prefix=ada/" \ --header "Authorization: Bearer " The application ID appears in wrangler containers list. The application is named after the Worker and the Durable Object class, such as sandbox-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_prefix filters by the start of the name, and state=active returns 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 dev do not appear. Related resources 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 Run code when a sandbox stops import { PackageManagers, Steps } from "~/components"; The runtime does not wake a Durable Object when its container stops. Your Durable Object watches the container while the Durable Object is in memory, and checks for a missed stop on every command and alarm. In this example, the Durable Object also stops idle sandboxes itself, so the record shows why each one stopped. 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. Record each stop At the top of src/index.ts, define the check interval, the idle limit, the inactivity timeout, and the record your Durable Object keeps: const CHECK_INTERVAL_MS = 60 * 1000; // Stop a sandbox after this long without requests const IDLE_LIMIT_MS = 10 * 60 * 1000; const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; interface SandboxRecord { state: "running" | "stopped"; lastActivity: number; reason?: string; exitCode?: number | null; stoppedAt?: number; } Keep the inactivity timeout longer than the check interval, so the container keeps running between alarms. While monitor() waits, the inactivity timeout does not start, so the alarm enforces the idle limit. If the alarms stop, the timeout still stops the container after the Durable Object leaves memory. For more information, refer to Sandbox lifetime. In MyContainer, add a constructor, a method that watches the container, and a method that records the stop: export class MyContainer extends DurableObject { // ... constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; // A restarted Durable Object loses the earlier `monitor()` call and the // inactivity timeout if (container?.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); this.watch(); } } watch() { this.ctx.container?.monitor().then( () => this.onStop("exited", 0), (error: unknown) => { const exitCode = (error as { exitCode?: number }).exitCode; if (exitCode === undefined) { return this.onStop(String(error), null); } return this.onStop("exited", exitCode); }, ); } async onStop(reason: string, exitCode: number | null) { const record = await this.ctx.storage.get("sandbox"); // Record each stop once, even when two checks report it if (record?.state !== "running") { return; } await this.ctx.storage.put("sandbox", { ...record, state: "stopped", reason, exitCode, stoppedAt: Date.now(), }); await this.ctx.storage.deleteAlarm(); console.log(JSON.stringify({ event: "sandbox.stopped", reason, exitCode })); } } When the main process fails, monitor() rejects with an error that has an exitCode. When your code calls destroy() with a reason, monitor() rejects with that reason. Put your own code after the record is saved. Add a method that records a stop that monitor() missed: export class MyContainer extends DurableObject { // ... async reconcile() { const record = await this.ctx.storage.get("sandbox"); if (record?.state === "running" && !this.ctx.container?.running) { // A stop that happened while the Durable Object was not in memory has // no exit code await this.onStop("unknown", null); } } } Replace exec(). It checks for a missed stop, watches a container it starts, schedules the next check, and records activity when the command finishes: export class MyContainer extends DurableObject { // ... // Count the commands that are running. A request in progress keeps the // Durable Object in memory, so the count does not need storage busy = 0; async exec(argv: string[]) { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } await this.reconcile(); if (!container.running) { container.start({ image: "cloudflare/debian-trixie", entrypoint: ["sleep", "infinity"], enableInternet: false, }); await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); this.watch(); } await this.ctx.storage.put("sandbox", { state: "running", lastActivity: Date.now(), }); await this.ctx.storage.setAlarm(Date.now() + CHECK_INTERVAL_MS); this.busy++; try { const process = await container.exec(argv); const output = await process.output(); return { stdout: new TextDecoder().decode(output.stdout), exitCode: output.exitCode, }; } finally { this.busy--; const record = await this.ctx.storage.get("sandbox"); if (record?.state === "running") { await this.ctx.storage.put("sandbox", { ...record, lastActivity: Date.now(), }); } } } } The alarm does not stop the sandbox while a command is running, even when the command runs longer than the idle limit. Add an alarm handler that checks the sandbox and stops it after IDLE_LIMIT_MS without requests. Then add methods that return the record and stop the sandbox: export class MyContainer extends DurableObject { // ... async alarm() { await this.reconcile(); const record = await this.ctx.storage.get("sandbox"); if (record?.state !== "running") { return; } if (this.busy === 0 && Date.now() - record.lastActivity > IDLE_LIMIT_MS) { await this.ctx.container?.destroy("idle"); return; } await this.ctx.storage.setAlarm(Date.now() + CHECK_INTERVAL_MS); } async status() { return (await this.ctx.storage.get("sandbox")) ?? null; } async stop() { await this.ctx.container?.destroy("stopped"); } } The reason passed to destroy() reaches monitor(), so an idle stop records idle and a stop through stop() records stopped. A Durable Object has one alarm. If your class already has an alarm() handler, merge the stop and idle checks into it, and set the alarm to the earliest time that any check needs. For more information, refer to Alarms. Add routes to your Worker. GET returns the record, DELETE stops the sandbox, and POST runs a command: export default { async fetch(request: Request, env: Env): Promise { const sandbox = env.MY_CONTAINER.getByName("sandbox"); if (request.method === "GET") { return Response.json(await sandbox.status()); } if (request.method === "DELETE") { await sandbox.stop(); return new Response(null, { status: 204 }); } const { argv } = (await request.json()) as { argv: string[] }; return Response.json(await sandbox.exec(argv)); }, }; Authenticate callers first, so other people cannot use or stop the sandbox. For more information, refer to Sandbox security. Deploy your Worker: Run a command, stop the sandbox, and read the record, on the workers.dev URL that Wrangler prints: curl https://..workers.dev --request POST --json '{"argv":["uname","-s"]}' curl https://..workers.dev --request DELETE curl https://..workers.dev { "state": "stopped", "lastActivity": 1790270030663, "reason": "stopped", "exitCode": null, "stoppedAt": 1790270031496 } Run another command and send no requests for 10 minutes. The next alarm stops the sandbox, and the record shows "reason": "idle". A deploy while the sandbox runs does not record a stop. Reasons in the record The reason field shows how the sandbox stopped: Reason Meaning exited The main process exited. exitCode holds its exit code. idle or stopped Your Durable Object called destroy() with this reason. unknown The container stopped while the Durable Object was not in memory, and a later command or alarm found it stopped. The exit code is not available. Any other text The error from monitor() when the container failed. Related resources Sandbox lifetime: what keeps a sandbox running, and what stops it. Save a sandbox automatically: save a snapshot before an idle stop. List a user's sandboxes monitor() and destroy() Durable Object alarms Call an authenticated API from a sandbox import { PackageManagers, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; Let code in a sandbox call an API that needs a token, while the token stays in your Worker. Requests from the sandbox reach an entrypoint in your Worker, which allows only the requests you choose and adds the token. In this example, the code reads your profile from the GitHub API. Prerequisites A Worker with a Durable Object that starts a container with the Durable Object scheduling policy, or a Worker that loads Dynamic Workers with a Worker Loader binding named LOADER. To create one, refer to Run a Linux command or Run JavaScript. A GitHub access token that can read your profile. Store the token In wrangler.jsonc, declare the token as a secret, then generate types: { "secrets": { "required": ["GITHUB_TOKEN"], }, } Store the token when Wrangler prompts for it: Call the API from a Container The container sends its request to api.github.com as usual. The Durable Object intercepts HTTPS requests to that hostname and delivers them to an entrypoint in your Worker, which adds the token. Add an entrypoint to your Worker that accepts one GitHub API request and adds the token: import { WorkerEntrypoint } from "cloudflare:workers"; export class GitHubGateway extends WorkerEntrypoint { async fetch(request: Request): Promise { const url = new URL(request.url); if ( request.method !== "GET" || url.hostname !== "api.github.com" || url.pathname !== "/user" || url.search !== "" ) { return new Response("Forbidden", { status: 403 }); } return fetch("https://api.github.com/user", { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${this.env.GITHUB_TOKEN}`, "User-Agent": "cloudflare-sandbox", "X-GitHub-Api-Version": "2022-11-28", }, }); } } The entrypoint allows only GET /user, so code in the sandbox cannot use the token for anything else. Allow each request your code needs, and nothing more. Add a method to your Durable Object that intercepts api.github.com and runs code that calls it: export class MyContainer extends DurableObject { // ... async getUsername(): Promise { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } await container.interceptOutboundHttps( "api.github.com", this.ctx.exports.GitHubGateway, ); if (!container.running) { container.start({ image: "cloudflare/debian-trixie", entrypoint: ["sleep", "infinity"], // The container can reach only the hostnames you intercept enableInternet: false, }); } const process = await container.exec( [ "node", "--input-type=module", "--eval", `const response = await fetch("https://api.github.com/user"); if (!response.ok) throw new Error("GitHub request failed"); console.log((await response.json()).login);`, ], { env: { // The intercept terminates TLS with a certificate that the container // CA certificate signs. Node.js reads the CA certificate from this path NODE_EXTRA_CA_CERTS: "/etc/cloudflare/certs/cloudflare-containers-ca.crt", }, }, ); const output = await process.output(); if (output.exitCode !== 0) { throw new Error(new TextDecoder().decode(output.stderr)); } return new TextDecoder().decode(output.stdout).trim(); } } Add a route to your Worker that returns the username: if (url.pathname === "/username") { const sandbox = env.MY_CONTAINER.getByName("github"); return Response.json({ username: await sandbox.getUsername() }); } The route uses a sandbox name of its own. An instance that is already running keeps the Internet access it started with, so enableInternet: false applies only to an instance that this method starts. Deploy your Worker, then send a request to /username on the workers.dev URL that Wrangler prints: curl https://..workers.dev/username { "username": "octocat" } Call the API from a Dynamic Worker A Dynamic Worker gets a method that calls one fixed GitHub endpoint, instead of the token. Add an entrypoint to your Worker with a method that calls GitHub with the token: import { WorkerEntrypoint } from "cloudflare:workers"; export class GitHub extends WorkerEntrypoint { async getUsername(): Promise { const response = await fetch("https://api.github.com/user", { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${this.env.GITHUB_TOKEN}`, "User-Agent": "cloudflare-sandbox", "X-GitHub-Api-Version": "2022-11-28", }, }); if (!response.ok) { throw new Error(`GitHub returned ${response.status}`); } const user = await response.json<{ login: string }>(); return user.login; } } In the fetch() handler of your Worker, pass the entrypoint to the Dynamic Worker as a binding. The handler needs its ctx argument for ctx.exports: const sandbox = env.LOADER.load({ compatibilityDate: "2026-10-01", mainModule: "code.js", modules: { "code.js": ` import { WorkerEntrypoint } from "cloudflare:workers"; export class Code extends WorkerEntrypoint { async run() { return this.env.GITHUB.getUsername(); } } `, }, env: { // Create a stub that the Dynamic Worker can receive GITHUB: ctx.exports.GitHub({ props: {} }), }, // Block every other `fetch()` and `connect()` call from the Dynamic Worker globalOutbound: null, }); const username = await sandbox .getEntrypoint }>("Code") .run(); return Response.json({ username }); The code calls this.env.GITHUB.getUsername(), and the method reads the token in your Worker. Deploy your Worker, then send a request to the workers.dev URL that Wrangler prints: curl https://..workers.dev { "username": "octocat" } Authenticate callers to your Worker first, so other people cannot use the quota of your token. For more information, refer to Sandbox security. Related resources Clone a private repository: the same pattern for Git. interceptOutboundHttps: intercept other Container destinations. Bindings: pass other methods to a Dynamic Worker. Egress control: restrict or audit requests from a Dynamic Worker. Secrets: rotate or manage the token. Clone a private repository import { PackageManagers, Render, Steps, TypeScriptExample, WranglerConfig, } from "~/components"; Clone a private repository into a sandbox while the access token stays in your Worker. If the clone URL contains the token, every command in the sandbox can read it. Instead, the Durable Object intercepts HTTPS requests from Git to github.com. An entrypoint in your Worker receives each request, checks it, and adds the token. 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. A GitHub access token with read access to the contents of the repository. Clone the repository Create a Dockerfile in your project root that installs Git: FROM node:24-trixie-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends ca-certificates git \ && rm -rf /var/lib/apt/lists/* CMD ["sleep", "infinity"] In wrangler.jsonc, name the repository, declare the token as a secret, and build the Dockerfile as a named image. Replace / with your repository: { "vars": { "REPOSITORY": "/", }, "secrets": { "required": ["GITHUB_TOKEN"], }, "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "git": { "dockerfile": "./Dockerfile", }, }, }, ], } Store the token when Wrangler prompts for it: Add an entrypoint to your Worker that accepts only the requests that fetch the repository, and adds the token: import { WorkerEntrypoint } from "cloudflare:workers"; export class GitGateway extends WorkerEntrypoint { async fetch(request: Request): Promise { const url = new URL(request.url); const repository = `/${this.env.REPOSITORY}.git`; const fetchesRepository = (request.method === "GET" && url.pathname === `${repository}/info/refs` && url.search === "?service=git-upload-pack") || (request.method === "POST" && url.pathname === `${repository}/git-upload-pack`); if (url.hostname !== "github.com" || !fetchesRepository) { return new Response("Forbidden", { status: 403 }); } const headers = new Headers(request.headers); const credentials = btoa(`x-access-token:${this.env.GITHUB_TOKEN}`); headers.set("Authorization", `Basic ${credentials}`); return fetch(new Request(request, { headers })); } } git-upload-pack serves clones and fetches. The entrypoint allows it for one repository and rejects every other request. Code in the sandbox cannot push through git-receive-pack or read other repositories, even when the token allows it. Add a method to your Durable Object that intercepts github.com and clones the repository: export class MyContainer extends DurableObject { // ... async clone(): Promise<{ exitCode: number; stderr: string }> { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } await container.interceptOutboundHttps( "github.com", this.ctx.exports.GitGateway, ); if (!container.running) { container.start({ image: container.images.git, // The container can reach only the hostnames you intercept enableInternet: false, }); // Keep the clone for later requests await container.setInactivityTimeout(10 * 60 * 1000); } const process = await container.exec( [ "git", "clone", "--depth=1", `https://github.com/${this.env.REPOSITORY}.git`, "/workspace", ], { env: { // The intercept terminates TLS with a certificate that the container // CA certificate signs. Git reads the CA certificate from this path GIT_SSL_CAINFO: "/etc/cloudflare/certs/cloudflare-containers-ca.crt", }, }, ); const output = await process.output(); return { exitCode: output.exitCode, // Git prints its progress to standard error stderr: new TextDecoder().decode(output.stderr), }; } } Add a route to your Worker that clones the repository: if (url.pathname === "/clone" && request.method === "POST") { const sandbox = env.MY_CONTAINER.getByName("repo"); const { exitCode, stderr } = await sandbox.clone(); return new Response(stderr, { status: exitCode === 0 ? 200 : 500 }); } The route uses a sandbox name of its own. An instance that is already running keeps the image and Internet access it started with, so it would have no Git. When Git fails, the route responds with 500 and the error from Git. While the sandbox keeps running, a second request fails this way, because /workspace already holds the clone. Authenticate callers first, so other people cannot read the repository through the sandbox. For more information, refer to Sandbox security. Deploy your Worker: Send a POST request to /clone on the workers.dev URL that Wrangler prints: curl https://..workers.dev/clone --request POST Cloning into '/workspace'... The repository is in /workspace, and git remote get-url origin prints the URL without a token. A git push from the sandbox fails with 403. The clone lasts until the instance stops. For more information, refer to Sandbox lifetime. Related resources Run tests from a Git repository: install and test a cloned repository. Call an authenticated API from a sandbox: the same pattern for an API. interceptOutboundHttps Credentials and network access import { DirectoryListing } from "~/components"; Any process in a sandbox can read a token that the sandbox holds. Keep the token in your Worker instead, and give the sandbox only the access it needs. A Container sends requests to a hostname that you choose, and your Worker intercepts each request and adds the token. A Dynamic Worker calls a method that your Worker passes it, and the method adds the token. A Container that starts with enableInternet: false reaches only the hostnames you intercept. A Dynamic Worker loaded with globalOutbound: null cannot make its own requests, and reaches your application only through the methods you pass. For every outbound option in each environment, refer to Handle outbound traffic for Containers and Egress control for Dynamic Workers. Preview a web application import { PackageManagers, Steps, TypeScriptExample } from "~/components"; 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// to the sandbox named . 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: 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 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. Add an inactivity timeout to your Durable Object. The constructor sets the timeout again when a restarted Durable Object finds the container running: const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; export class MyContainer extends DurableObject { 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. Then start the server and wait until it responds: export class MyContainer extends DurableObject { // ... // Skip the check after the first response private ready = false; private async startServer(): Promise { 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; } } Forward requests from the fetch() handler of your Durable Object: export class MyContainer extends DurableObject { // ... async fetch(request: Request): Promise { 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 }); } } } Route preview paths from your Worker to your Durable Object: function forward( sandbox: DurableObjectStub, request: Request, path: string | undefined, ): Promise | 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 { 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; The server receives each path without the /previews/ prefix. Links that start with / leave the preview, so use relative links or serve each preview on its own hostname. 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. An encoded / does not match, so this route cannot reach a sandbox that another route names with a /, such as /. 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. Deploy your Worker: Request a page from the preview named ada. Replace the example hostname with the workers.dev URL that Wrangler prints: curl https://..workers.dev/previews/ada/hello { "path": "/hello", "uptime": 0 } Wait a few seconds and send the same request again. The uptime increases, because the same server handles both requests: { "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: function bridge(response: Response): 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: WebSocket, to: WebSocket) => { 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 {} }); }; relay(server, upstream); relay(upstream, server); // Pass on the subprotocol that the server chose. A browser closes a // connection that requested one and receives none. const headers = new Headers(); const protocol = response.headers.get("Sec-WebSocket-Protocol"); if (protocol) { headers.set("Sec-WebSocket-Protocol", protocol); } return new Response(null, { status: 101, webSocket: client, headers }); } In the fetch() handler of your Durable Object, bridge responses that open a WebSocket: try { const response = await container.getTcpPort(port).fetch(request); return response.webSocket ? bridge(response) : response; } catch { With the bridge, the sandbox keeps running while any page has a connection open, even when no messages arrive. After the last connection closes, the inactivity timeout applies again. Use hot reloading with a development server Development servers such as Vite reload the page over a WebSocket. With the bridge from the previous section, the reload connection reaches the server. Two server settings decide whether hot reloading works: The server checks the Host header of each request. Vite rejects hostnames that it does not know, so add the preview hostname to server.allowedHosts. Under /previews//, requests arrive with the Host header container. The server builds absolute URLs for its assets and for the reload connection from its base path. Under /previews//, those URLs leave the preview. Serve each preview on its own hostname, where paths reach the server unchanged. The following Vite configuration accepts requests for previews on their own hostnames under example-previews.com: import { defineConfig } from "vite"; export default defineConfig({ server: { host: "0.0.0.0", port: 8080, allowedHosts: [".example-previews.com"], }, }); Share a preview with a link Give someone access to one preview for a limited time. In this example, the Durable Object stores random tokens with an expiry time. The Worker forwards requests under /shared/// only while the token is valid. Add methods that create and check tokens to your Durable Object: export class MyContainer extends DurableObject { // ... async share(ttl: number): Promise { const token = crypto.randomUUID(); await this.ctx.storage.put(`share:${token}`, Date.now() + ttl); return token; } async isShared(token: string): Promise { const key = `share:${token}`; const expires = await this.ctx.storage.get(key); if (expires !== undefined && expires <= Date.now()) { await this.ctx.storage.delete(key); return false; } return expires !== undefined; } } In the fetch() handler of your Worker, add routes that create a link and serve shared previews, before the 404 response: const share = /^\/shares\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/.exec(url.pathname); if (share && request.method === "POST") { const sandbox = env.MY_CONTAINER.getByName(share[1]); const token = await sandbox.share(60 * 60 * 1000); return Response.json({ url: `${url.origin}/shared/${share[1]}/${token}/` }); } const shared = /^\/shared\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/([^/]+)(\/.*)?$/.exec(url.pathname); if (shared) { const sandbox = env.MY_CONTAINER.getByName(shared[1]); if (!(await sandbox.isShared(shared[2]))) { return new Response("Not found", { status: 404 }); } return forward(sandbox, request, shared[3]); } Authenticate the POST request, so that only the owner of a preview can create links to it. Code in a preview runs on this hostname and can send requests to this route with the visitor's cookies. To prevent that, serve the route from another hostname, or authenticate it with a credential that pages in the browser cannot send, such as a secret that your application backend adds. The shared route needs no authentication, because the token is the permission. Deploy your Worker, then create a link to the preview named ada: curl https://..workers.dev/shares/ada --request POST { "url": "https://..workers.dev/shared/ada//" } Open the URL in a browser. The preview loads for one hour. After that, and for any other token or preview name, the Worker responds with 404. Anyone with the link can open the preview until it expires. Each token belongs to one preview name. To revoke a link early, delete its key from Durable Object storage. Stop a preview Stop the server when the preview is no longer needed, instead of waiting for the inactivity timeout: Add a method that stops the container to your Durable Object: export class MyContainer extends DurableObject { // ... async stop(): Promise { if (this.ctx.container?.running) { await this.ctx.container.destroy(); } this.ready = false; } } In your Worker, call it for DELETE /previews/, before forwarding: if (!preview[2] && request.method === "DELETE") { await sandbox.stop(); return new Response(null, { status: 204 }); } Deploy your Worker, and stop the preview named ada: curl https://..workers.dev/previews/ada --request DELETE The Worker responds with 204. The next request to the preview starts a new server, with an uptime of 0. Related resources Serve previews on their own hostnames Sandbox lifetime Sandbox security getTcpPort() Serve previews on their own hostnames import { PackageManagers, Steps, TypeScriptExample, WranglerConfig, } from " /components"; import { PreviewHostname } from "/components/react/sandbox/PreviewHostname"; Serve each sandbox preview from its own hostname, such as ada.example-previews.com. Code in a preview runs in the visitor's browser, and each preview hostname is a separate origin. Code in one preview cannot read your application pages or the storage of another preview. Paths reach the server unchanged, so applications do not need a base path. In https://ada.example-previews.com/app/, a wildcard DNS record and the route *.example-previews.com/* send the request to your Worker. Your Worker reads the sandbox name ada and calls getByName("ada"). A name that is not a valid DNS label gets a 404. The path /app/ reaches the web server in the container unchanged, with the preview hostname in the Host header. Prerequisites A Durable Object whose fetch() handler forwards requests to a web server in its container. To build one, refer to Preview a web application. A domain for previews on Cloudflare, such as example-previews.com, with Cloudflare managing its DNS. Use a domain that your application does not use. Route preview hostnames to your Worker In the DNS settings of the preview domain, add a proxied wildcard record. For example, add an AAAA record with the name * and the content 100::, with the proxy status Proxied. The record lets Cloudflare receive requests for every preview hostname. The Worker route handles those requests, so Cloudflare never contacts the address in the record. In wrangler.jsonc, add the preview domain as a variable and route its subdomains to your Worker. Replace example-previews.com with your preview domain: { "vars": { "PREVIEW_DOMAIN": "example-previews.com", }, "routes": [ { "pattern": "*.example-previews.com/*", "zone_name": "example-previews.com", }, ], "workers_dev": true, } Wrangler turns off the workers.dev URL when routes is set. workers_dev: true keeps it for requests that manage previews. Generate types for the new variable: In the fetch() handler of your Worker, send each preview hostname to the Durable Object with the same name: export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const suffix = `.${env.PREVIEW_DOMAIN}`; // A preview hostname reaches only the sandbox with the same name. if (url.hostname.endsWith(suffix)) { const name = url.hostname.slice(0, -suffix.length); // Accept only sandbox names that are valid DNS labels if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) { return new Response("Not found", { status: 404 }); } // Forward with the `http:` scheme, because `getTcpPort().fetch()` // does not accept `https:` URLs url.protocol = "http:"; return env.MY_CONTAINER.getByName(name).fetch(new Request(url, request)); } // Other hostnames manage previews. return new Response("Not found", { status: 404 }); }, } satisfies ExportedHandler; The server receives the full path and the preview hostname in the Host header. Every request to a preview hostname goes to the preview. Keep routes that manage previews, such as stopping one, on other hostnames, so code in a preview cannot call them from its own origin. Authenticate preview visitors on the preview hostname, and do not send your application session cookie there. Deploy your Worker: Open the preview named ada in your browser, and store a value in its browser console. Replace example-previews.com with your preview domain: https://ada.example-previews.com/ localStorage.setItem("name", "ada"); Open the preview named grace, and read the value in its browser console: https://grace.example-previews.com/ localStorage.getItem("name"); The console prints null. Each preview hostname is a separate origin, so grace cannot read the storage of ada. Serve previews under a subdomain To serve previews under a subdomain of a domain you already use, such as previews.example.com, name the wildcard record and the route after that subdomain: In the DNS settings of example.com, add a proxied AAAA record with the name *.previews and the content 100::. A wildcard record named * does not apply below previews.example.com if that hostname has its own record, and it also matches every other unused subdomain of example.com. A record named *.previews matches only preview hostnames. In wrangler.jsonc, set PREVIEW_DOMAIN to the subdomain and route its subdomains to your Worker: { "vars": { "PREVIEW_DOMAIN": "previews.example.com", }, "routes": [ { "pattern": "*.previews.example.com/*", "zone_name": "example.com", }, ], "workers_dev": true, } :::caution[Certificates for deeper hostnames] Universal SSL covers a domain and its first-level subdomains, such as ada.example-previews.com. It does not cover deeper hostnames such as ada.previews.example.com. To serve previews under a subdomain, add Total TLS or an advanced certificate for it. Without one, browsers show a certificate error on preview hostnames. ::: Previews under a domain that your application also uses share cookies with your application. Browsers send a cookie set with Domain=example.com to every subdomain, including every preview under previews.example.com. Set cookies on preview hostnames Code in one preview can set a cookie with Domain=example-previews.com, and every other preview then receives that cookie. Set cookies on preview hostnames without a Domain attribute, and do not trust a cookie because it arrived on a preview hostname. Troubleshooting The server in the container uses HTTPS: Serve plain HTTP from the container. Cloudflare handles TLS for the preview hostname, and your Worker forwards each request to the container over HTTP. A server that only accepts HTTPS, such as one started with https.createServer() in Node.js or with a framework's HTTPS flag, cannot handle these requests. Start the server with HTTP instead. Related resources Preview a web application Preview workspace example: a deployable Worker that runs a Vite development server on a separate hostname for each sandbox. Sandbox security Routes DirectoryBackup API import { MetaInfo, Type, TypeScriptExample } from "~/components"; DirectoryBackup saves a directory from a running container to an R2 bucket and restores it later into a container, including a container that runs a newer image. The container sends the backup through DirectoryBackupGateway in your Worker, so the container needs no Internet access and no R2 credentials. The gateway lets the container reach only the object that the operation needs. DirectoryBackup does not start or stop the container. import { DirectoryBackup, type DirectoryBackupRecord, } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; export { DirectoryBackupGateway } from "@cloudflare/sandbox"; export class MyContainer extends DurableObject { readonly #backups: DirectoryBackup; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); if (!ctx.container) { throw new Error("No container is configured"); } // ctx.container stays the same object while the Durable Object runs. this.#backups = new DirectoryBackup( ctx.container, ctx.exports.DirectoryBackupGateway, { binding: "BACKUPS", prefix: "workspaces/" }, ); } // The container must already be running. async saveWorkspace() { const backup = await this.#backups.backup({ dir: "/workspace/app", exclude: ["node_modules/"], }); // Keep the record. The package does not list backups. await this.ctx.storage.put("workspace", backup); } async restoreWorkspace() { const backup = await this.ctx.storage.get("workspace"); if (backup) { await this.#backups.restore(backup); } } } For a complete Worker, refer to Back up a directory to R2. Requirements In addition to the package requirements, DirectoryBackup has these requirements: The Worker has an R2 bucket binding. The main module of the Worker re-exports DirectoryBackupGateway from @cloudflare/sandbox: export { DirectoryBackupGateway } from "@cloudflare/sandbox"; Do not route HTTP requests to DirectoryBackupGateway. DirectoryBackup new DirectoryBackup( container: Pick, gateway: DirectoryBackupGatewayBinding, storage: DirectoryBackupStorage, ) container — the container from this.ctx.container. gateway — this.ctx.exports.DirectoryBackupGateway. storage — where backups are stored. Refer to DirectoryBackupStorage. An invalid storage throws TypeError. Methods backup backup(options: DirectoryBackupOptions): Promise Saves options.dir as one object in the bucket and returns a DirectoryBackupRecord. For the options, refer to DirectoryBackupOptions. What a backup contains lists what the object keeps. backup() reads the directory while processes can still change it, and it does not detect changes. A file that changes during the backup can be saved partly old and partly new. A backup is consistent only when no process writes to dir while it runs. A backup that fails or is canceled returns no record, and backup() aborts its multipart upload. If the Durable Object restarts during a backup, the upload stays in the bucket until an R2 lifecycle rule aborts it. restore restore(backup: DirectoryBackupRecord, options?: DirectoryRestoreOptions): Promise Replaces the target directory with the contents of the backup. The target is dir from the record, unless you pass another absolute path in options.dir. The parent directory of the target must exist. The target does not need to exist. The target must not be a mount point or contain one, such as an S3Mount mount. Otherwise restore() fails with EBUSY, including when a mount appears during the restore. Unmount first, for example with S3Mount.unmount(), and mount again after the restore. restore() extracts the backup into a new directory beside the target and checks the size and SHA-256 of the object against the record. It then replaces the target with the new directory in one atomic rename. When restore() throws SandboxFileError or SandboxBackupError, or is canceled before it verifies the download, it leaves the target as it was and removes the new directory. A process that has its working directory inside the target, or has a file in the target open, keeps using the old directory or file. Restart such processes after restore(), or have them reopen files by absolute path. The filesystem needs free space for the old and the restored directory at the same time. restore() stops with ENOSPC before free space falls below 5% of the filesystem or 256 MiB, whichever is smaller. The root filesystem of a deployed container compresses files. restore() writes files without compression. The restored files take more disk space until a process rewrites them. delete delete(backup: DirectoryBackupRecord, options?: DirectoryBackupDeleteOptions): Promise Deletes the backup object. The container does not need to be running. Deleting an object that does not exist succeeds. A restore() that reads the object at the same time fails with BACKUP_NOT_FOUND or BACKUP_INTEGRITY. intercept intercept(): Promise Routes backup traffic from the container to DirectoryBackupGateway, which refuses every request until a backup or restore starts. intercept() does not start the container. {/* Remove the second sentence and the last list item when a hostname intercept registered after interceptAllOutboundHttp() receives its requests. */} backup() and restore() add this route themselves. If the Durable Object also calls interceptAllOutboundHttp(), call intercept() before it. Otherwise the catch-all receives the backup traffic from the container, and backup() and restore() fail with BACKUP_TRANSFER. Call intercept() in the setup that runs after you start the container, and again when a restarted Durable Object finds the container running. Backups and restores in that container then keep their route. Do not call intercept() while a backup or restore can be running. It replaces the access of the running operation, and the operation fails. A container that registered the catch-all first keeps sending backup traffic to it. Restart the container to use intercept(). #setup: Promise | null = null; // Call before each use of the container. #ensureRunning(container: Container): Promise { // Set up each new container, and a running container // after this Durable Object restarts. if (this.#setup === null || !container.running) { this.#setup = this.#setUp(container).catch((error) => { this.#setup = null; throw error; }); } return this.#setup; } async #setUp(container: Container): Promise { if (!container.running) { container.start({ image: container.images.sandbox, enableInternet: false, }); } await this.#backups.intercept(); await container.interceptAllOutboundHttp( this.ctx.exports.Outbound({ props: {} }), ); } Types DirectoryBackupRecord The record that backup() returns. It is a plain object that you can store in Durable Object storage, a database, or JSON. The package keeps no list of backups. id — UUID of the backup. The object key is .tar.zst. dir — absolute path of the directory that was backed up, and the default restore target. size — size of the stored object in bytes. name — the name passed to backup(). sha256 — SHA-256 of the stored object, as 64 lowercase hexadecimal characters. Every restore() checks it. format — archive format of the object. restore() and delete() use the object at .tar.zst in the bucket and prefix that the DirectoryBackup instance was created with, so pass a record to an instance whose storage holds that object. restore() checks the size and SHA-256 of the object against the record. A record that does not match this shape throws TypeError. DirectoryBackupStorage binding — name of the R2 bucket binding in the Worker env, such as "BACKUPS". prefix — key prefix for the objects. A prefix that is not empty must end in /. The default is no prefix. DirectoryBackupOptions dir — absolute path of the directory to back up. name — label stored in the record and in the custom metadata of the object as name. exclude — patterns in .gitignore syntax, relative to dir. "node_modules/" excludes every directory named node_modules. "/build" excludes only build at the top of dir. gitignore — also apply the .gitignore files inside dir and .git/info/exclude. Global Git excludes do not apply. The image does not need git. The default is false. signal — cancels the operation. Refer to Cancellation. DirectoryRestoreOptions dir — absolute path of the directory to replace. The default is dir from the record. signal — cancels the operation. Refer to Cancellation. DirectoryBackupDeleteOptions signal — an aborted signal rejects the call before the object is deleted. An option that a method does not accept, or an option of the wrong type, throws TypeError. What a backup contains backup() walks dir without following symbolic links and does not enter other filesystems. A mount point inside dir becomes an empty directory in the backup. restore() works the other way: it refuses a target that contains a mount point. For more information, refer to restore. backup() includes every other path, including .git, unless exclude or gitignore leaves it out. A backup keeps: File contents Directories, including empty ones Symbolic links, as links Hard links between files inside dir Permission bits, and numeric user and group IDs Modification times, to the nanosecond A backup does not keep: Sockets, devices, and FIFOs The setuid and setgid bits Extended attributes, ACLs, and file capabilities Holes in sparse files, which restore as zeros Access times When the image user is root, restore() sets the saved user and group IDs as numbers, even when the image has no user with that ID. Otherwise, the restored files belong to the image user. Operations in one container One backup() or restore() runs at a time in a container. Other calls wait for it to finish, including calls from other DirectoryBackup instances and from a Durable Object that restarted. Promise.all() over several directories runs them one after another. delete() does not wait. Cancellation DirectoryBackup sets no timeouts and does not retry. Aborting signal rejects the call with the abort reason without waiting for the helper process, including a call that is still waiting for another operation. The helper process in the container then removes what the operation created and exits. After restore() verifies the download, it finishes replacing the target even if signal aborts. The call then resolves when the target is replaced, or rejects with the error from replacing it. When the Durable Object restarts during an operation, the result of the call is lost, and the helper process exits. A backup is not recorded. A restore has either replaced the whole target or left it as it was. Restoring the same backup again gives the same result either way. DirectoryBackupGateway DirectoryBackupGateway is a WorkerEntrypoint that moves backups between the container and the R2 bucket. The container can reach only the object of the operation in progress. During a backup, it uploads parts of that object. During a restore, it reads byte ranges of it. It cannot list or delete objects or reach another key. The SHA-256 check detects an object that changed after the backup. The container sends HTTP requests to the gateway at backups.sandbox.internal, which DirectoryBackup routes with interceptOutboundHttp(). The Durable Object creates, completes, and aborts uploads, and deletes objects, through RPC calls to the gateway. The first operation in a container, or intercept(), adds the intercept target. Each later operation replaces the handler on that target and does not add a target. Limits The container uploads the compressed backup in parts of 16 MiB. An R2 multipart upload holds up to 10,000 parts, so a compressed backup can be at most 160,000 MiB, about 156 GiB. A larger backup fails with BACKUP_TRANSFER. Local development Under wrangler dev, the container runs in Docker, and restore() cannot replace a directory that is part of the image. The restore fails with SandboxFileError code EXDEV and leaves the directory as it was. restore() can replace a directory that was created after the container started, a directory in a volume, or a target that does not exist yet. Errors SandboxBackupError The backup object is missing, changed, or could not be transferred. Field Type Description name "SandboxBackupError" Error name code SandboxBackupErrorCode Error code operation DirectoryBackupOperation Method that failed: backup or restore path string Directory being backed up or restored detail string Error description code is one of the following values: Code Meaning BACKUP_NOT_FOUND The object does not exist BACKUP_INTEGRITY The object does not match the record, or it is not a valid archive. The target was not changed. backup() also throws it when R2 stored a different size than the container uploaded, and deletes the object BACKUP_TRANSFER An upload or download between the container and DirectoryBackupGateway failed, including when storage.binding does not name an R2 bucket binding. detail contains the HTTP status. No record was returned, and no directory was changed The package exports the SandboxBackupErrorCode and DirectoryBackupOperation types, which list these codes and methods. SandboxBackupError is not a class. Use SandboxBackupError.is(error) instead of instanceof. It recognizes errors thrown in the same Worker and errors returned through Durable Object RPC. SandboxFileError Linux rejected the operation in the container. operation is backup or restore. Refer to SandboxFileError. Code Condition EBUSY The restore target is a mount point or has one inside it EINVAL An exclude pattern is invalid ENOENT dir from the backup or the parent of the restore target does not exist ENOSPC The restore would fill the filesystem ENOTDIR dir from the backup or the restore target is a symbolic link or is not a directory EXDEV Linux could not swap the restored directory into place, as for a directory from the image under wrangler dev Other codes, such as EACCES, come from reading a file during a backup. SandboxProtocolError DirectoryBackup could not complete its exchange with the helper binary. Refer to SandboxProtocolError. Other errors DirectoryBackup does not wrap errors from the runtime, from R2, or from its inputs. Condition Error Invalid options, record, or storage TypeError storage.binding does not name an R2 bucket binding in backup() or delete() TypeError from DirectoryBackupGateway The container is not running Error from exec() signal is aborted The abort reason R2 rejects creating, completing, or aborting an upload, or deleting an object The error from the R2 binding An intercept cannot be added Error from interceptOutboundHttp() Related resources Back up a directory to R2 Save and restore a workspace Durable Object Container R2 Workers API Files API import { TypeScriptExample } from "~/components"; Files reads and changes files and directories in a running container. It does not start or stop the container. Each method runs the sandbox-shim helper binary in the container through exec(). Every method except readFile() waits for the helper to finish; readFile() returns once the file is open and streams its contents. Paths follow Linux rules, and Files does not restrict which paths a caller can open. import { Files } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; export class MyContainer extends DurableObject { private readonly files: Files; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); if (!ctx.container) { throw new Error("No container is configured"); } // ctx.container stays the same object while the Durable Object runs. this.files = new Files(ctx.container); } // The container must already be running. async readInput(): Promise { await this.files.mkdir("/workspace", { recursive: true }); await this.files.writeFile("/workspace/input.txt", "hello\n"); return this.files.readFile("/workspace/input.txt"); } } For a complete Worker, refer to Move files in and out of a sandbox. Requirements Before you use Files, meet the package requirements: the helper binary in the image, the nodejs_compat flag, and a running container. Files has no other requirements. Files new Files(container: Pick) container — the container from this.ctx.container, or any object with a compatible exec() method. Paths A path is absolute, or relative to the cwd option. A relative path requires cwd. cwd must be absolute. A path cannot be empty or contain a null character. An invalid path or cwd throws TypeError before the helper process starts. A relative path is joined onto cwd, and Linux resolves the joined path. An absolute path ignores cwd. A cwd that does not exist rejects with SandboxFileError ENOENT. So does a missing directory anywhere in a path. Paths resolve through symbolic links as they do in Linux, except where a method states otherwise. Options Every method accepts FileOperationOptions: interface FileOperationOptions { cwd?: string; user?: string; signal?: AbortSignal; } Option Type Description cwd string Absolute directory that resolves a relative path. For rename(), it resolves both paths. user string Numeric Linux user and group IDs as uid:gid, such as "1000:1000". Passed to exec(). When omitted, the helper runs as the image user. A user ID alone, or a user or group name, throws TypeError. signal AbortSignal Cancels the operation and kills its helper process. A signal that fires after the operation finishes has no effect, including an AbortSignal.timeout() that outlives the call. The call rejects with the abort reason: AbortError from abort() without a reason, or TimeoutError from AbortSignal.timeout(). Cancellation can leave the partial effects that each method lists. mkdir() also accepts recursive. remove() also accepts recursive and force. An option a method does not accept, or an option of the wrong type, throws TypeError before the helper process starts. Of these options, only user and signal reach exec(). Methods readFile readFile(path: string, options?: FileOperationOptions): Promise Reads the contents of a file as bytes. Returns a Response with status 200 and no Content-Type header. The body applies backpressure to the helper process. Rejects with SandboxFileError when the file cannot be opened, such as ENOENT for a missing file or EISDIR for a directory. An error after the file opens errors the response body instead of rejecting the call. Read the Response like any HTTP response. text(), json(), arrayBuffer(), and blob() load the whole file into memory. For a large file or a file of unknown size, stream body instead. writeFile writeFile( path: string, content: | string | ArrayBuffer | ArrayBufferView | Blob | ReadableStream, options?: FileOperationOptions, ): Promise Creates a file, or truncates an existing file, then streams content into it. content is a FileContent value, such as a string, a Uint8Array, or a ReadableStream. The parent directory must exist. A missing parent rejects with ENOENT. A directory at path rejects with EISDIR. The file opens before a ReadableStream is consumed. A later failure can leave the file created, truncated, or partially written. If the content stream errors, the call rejects with the error from the stream instead of a SandboxFileError. rename() replaces an existing destination in one step. To keep other processes from reading a partially written file, write the file to a temporary path and then rename it: await files.writeFile(`${path}.partial`, content, { cwd }); await files.rename(`${path}.partial`, path, { cwd }); stat stat(path: string, options?: FileOperationOptions): Promise Returns SandboxFileStat metadata for a path. Follows a symbolic link at the end of the path. lstat lstat(path: string, options?: FileOperationOptions): Promise Returns SandboxFileStat metadata for a path without following a symbolic link at the end of the path. For a symbolic link, type is "symlink" and size is the length of the link target. readDirectory readDirectory( path: string, options?: FileOperationOptions, ): Promise Returns the immediate entries of a directory as SandboxDirectoryEntry values. The list is not sorted. The filesystem decides the order. The list does not include . or ... path can be a symbolic link to a directory. The method follows no other symbolic link. The method does not recurse or return metadata for each entry. A file at path rejects with ENOTDIR. An entry name that is not valid UTF-8 rejects the whole call with EILSEQ. To list a directory tree, walk it with readDirectory(). This walk does not follow symbolic links to directories: import type { Files } from "@cloudflare/sandbox"; async function* walk(files: Files, directory: string): AsyncGenerator { for (const entry of await files.readDirectory(directory)) { const path = `${directory}/${entry.name}`; yield path; if (entry.type === "directory") { yield* walk(files, path); } } } Each call to readDirectory() starts one process in the container. For a large tree, one container.exec(["find", directory]) call is faster. To cancel it, pass signal to exec(). mkdir mkdir(path: string, options?: MkdirOptions): Promise type MkdirOptions = FileOperationOptions & { recursive?: boolean; }; Creates a directory. With recursive: true, the method creates missing parent directories. Without it, a missing parent rejects with ENOENT. recursive defaults to false. An existing file at path rejects with EEXIST. An existing directory at path also rejects with EEXIST, unless recursive is true. A file in place of a parent directory rejects with ENOTDIR. A failed recursive call can leave the parents it created. rename rename( source: string, destination: string, options?: FileOperationOptions, ): Promise Renames a file, directory, or symbolic link with Linux rename rules. An existing file at destination is replaced. Renaming a directory onto a non-empty directory rejects with ENOTEMPTY. Renaming across filesystems rejects with EXDEV. The method does not fall back to copying and deleting. SandboxFileError from this method sets both path and destination. remove remove(path: string, options?: RemoveOptions): Promise type RemoveOptions = FileOperationOptions & { recursive?: boolean; force?: boolean; }; Removes a file or symbolic link. A symbolic link is removed, not its target. A directory requires recursive: true. Without it, a directory rejects with EISDIR, even when force is true. With recursive: true, the method removes the directory tree without following symbolic links inside it. With force: true, a missing target resolves instead of rejecting with ENOENT. A failed recursive call can leave part of the tree. Types FileContent type FileContent = string | ArrayBuffer | ArrayBufferView | Blob | ReadableStream; A string is written as UTF-8. SandboxFileStat Field Type Description type SandboxFileType File type size bigint Size in bytes mode number Linux mode, including the file type bits. A regular file with permissions 644 has mode 0o100644. uid number Owner user ID gid number Owner group ID accessedAt Date Last access time modifiedAt Date Last content modification time changedAt Date Last metadata change time JSON.stringify() and Response.json() throw TypeError for a bigint. Convert size with Number() or String() before serializing a SandboxFileStat. bigint and Date values pass through Durable Object RPC unchanged. SandboxDirectoryEntry Field Type Description name string Entry name type SandboxFileType Entry type. A symbolic link has type "symlink". SandboxFileType type SandboxFileType = | "file" | "directory" | "symlink" | "blockDevice" | "characterDevice" | "fifo" | "socket"; Errors SandboxFileError Linux rejected a file operation. Field Type Description name "SandboxFileError" Error name code string Linux error name, such as ENOENT. UNKNOWN when the runtime has no name for the error. operation string Method that failed: readFile, writeFile, stat, lstat, readDirectory, mkdir, rename, or remove path string Path passed to the method. For rename(), the source path. destination string Destination path. Set only by rename(). undefined for every other method. detail string Error description from the helper The message has the form '': , for example readFile 'notes.txt': No such file or directory (os error 2). SandboxFileError is not a class. Use SandboxFileError.is(error) instead of instanceof. It recognizes errors thrown in the same Worker and errors returned through Durable Object RPC. SandboxProtocolError Files could not complete its exchange with the helper binary. A cloudflare/sandbox image tag that does not match the installed package version can cause this error. Field Type Description name "SandboxProtocolError" Error name code "SANDBOX_PROTOCOL_ERROR" Error code detail string Error description Use SandboxProtocolError.is(error) to recognize the error. Other errors Files does not wrap errors from the runtime or from its inputs. Condition Error Invalid path, cwd, or other option TypeError The container is not running Error from exec() The image does not contain the helper binary Error from exec() that names /usr/local/bin/sandbox-shim signal is aborted The abort reason The content stream of writeFile() errors The error from the stream Related resources Move files in and out of a sandbox Durable Object Container Sandbox lifetime @cloudflare/sandbox import { DirectoryListing, PackageManagers, TypeScriptExample, WranglerConfig, } from "~/components"; @cloudflare/sandbox works with a container you start from a Durable Object. Its classes run helper processes in the running container through exec(). The package does not start, stop, or monitor the container. For start(), exec(), snapshots, and the other methods of the container itself, refer to the Durable Object container API. Install Requirements Every class in the package has these requirements. A class page lists any others. The container image contains the helper binary at /usr/local/bin/sandbox-shim. Copy it from the cloudflare/sandbox image whose tag matches the installed @cloudflare/sandbox version: COPY --from=docker.io/cloudflare/sandbox: /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim The helper is a statically linked linux/amd64 binary. It runs in any linux/amd64 image. The Worker has the nodejs_compat compatibility flag. The package reads Linux error names through node:os. The container is running when a method runs the helper. A class page lists the methods that do not need it, such as DirectoryBackup.delete(). Example configuration This configuration meets every requirement. The Dockerfile copies the helper into a Debian Trixie image with Node.js 24, and sleep infinity keeps the container running: FROM node:24-trixie-slim COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim RUN mkdir /workspace CMD ["sleep", "infinity"] The Wrangler configuration sets nodejs_compat, builds the Dockerfile as the image named workspace, and declares MyContainer as a Durable Object class: { "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-files", "main": "src/index.ts", "upload_source_maps": true, "compatibility_date": "$today", "compatibility_flags": ["nodejs_compat"], "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "workspace": { "dockerfile": "./Dockerfile", }, }, }, ], "durable_objects": { "bindings": [ { "class_name": "MyContainer", "name": "MY_CONTAINER", }, ], }, "exports": { "MyContainer": { "type": "durable-object", "storage": "sqlite", }, }, } The Durable Object starts the workspace image and passes the container to a class from the package: import { Files } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject { private readonly container: Container; private readonly files: Files; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("No container is configured"); } this.container = container; // ctx.container stays the same object while the Durable Object runs. this.files = new Files(container); // A restarted Durable Object sets the timeout again. if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } async listWorkspace() { // Files does not start the container. if (!this.container.running) { this.container.start({ image: this.container.images.workspace, enableInternet: false, }); await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } return this.files.readDirectory("/workspace"); } } For more information about building images, refer to images. Classes S3Mount API import { MetaInfo, Type, TypeScriptExample } from "~/components"; S3Mount mounts an S3-compatible bucket, or a prefix in one, at a path in a running container. The container runs s3fs, which sends storage requests to S3Gateway in your Worker. S3Gateway checks each request and signs it with credentials that the container never receives. S3Mount does not start or stop the container. import { S3Mount } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; export { S3Gateway } from "@cloudflare/sandbox"; export class MyContainer extends DurableObject { private readonly mounts: S3Mount; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); if (!ctx.container) { throw new Error("No container is configured"); } // ctx.container stays the same object while the Durable Object runs. this.mounts = new S3Mount(ctx.container, ctx.exports.S3Gateway); } // The container must already be running. async mountModels() { await this.mounts.mount({ mountPath: "/mnt/models", source: { type: "s3", endpoint: `https://${this.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, region: "auto", bucket: "models", credentials: { type: "static", accessKeyId: this.env.R2_ACCESS_KEY_ID, secretAccessKey: this.env.R2_SECRET_ACCESS_KEY, }, }, keyPrefix: "production", access: "read-only", }); } } For a complete Worker, refer to Mount an R2 bucket. Requirements In addition to the package requirements, S3Mount has these requirements: The container image contains FUSE 3 and s3fs. In Debian and Ubuntu images, install the fuse3 and s3fs packages. The main module of the Worker re-exports S3Gateway, so that this.ctx.exports.S3Gateway exists: export { S3Gateway } from "@cloudflare/sandbox"; Do not route HTTP requests to S3Gateway. S3Mount new S3Mount(container: Pick, gateway: S3GatewayBinding) container — the container from this.ctx.container. gateway — this.ctx.exports.S3Gateway. Methods mount mount(request: S3MountRequest, options?: S3MountOperationOptions): Promise Mounts the source at request.mountPath. The result depends on what the path holds: Nothing: mount() creates the directory and mounts the source. A mount with the same settings: mount() reuses it. Leftover state from a failed or canceled mount() with the same settings: mount() repairs it. Another filesystem, or a mount with different settings: mount() throws SandboxS3MountError with code S3_MOUNT_CONFLICT. To change the settings, call unmount() first. Each new mount adds one outbound intercept target to the container. Reusing a mount does not add one. unmount() does not remove one. Mounts share the container intercept limit with the intercepts your own code registers. Once the container reaches the limit, a mount with new settings fails. {/* Remove this paragraph when a hostname intercept registered after interceptAllOutboundHttp() receives its requests. */} If the Durable Object also calls interceptAllOutboundHttp(), call mount() before it, right after you start the container. Otherwise the catch-all receives the storage requests from the container, and mount() fails with cannot verify the new FUSE connection. A container that registered the catch-all first keeps sending storage requests to it. Restart the container to mount. S3Mount does not retry a failed operation. Call inspect() before you retry. inspect inspect(mountPath: string, options?: S3MountOperationOptions): Promise Reports the state of a path without changing it. inspect() waits for a mount() or unmount() in progress on the same path. It then reads the mount state in the container and sends one list request for the prefix through S3Gateway. Reading the mount state can block while the filesystem does not respond. inspect() has no timeout. Pass signal to set one. Credentials that can read objects but cannot list the prefix produce a rejected upstream status. unmount unmount(mountPath: string, options?: S3MountOperationOptions): Promise Denies further storage requests for the mount, then unmounts the path. On a path with no mount, unmount() succeeds without changes. unmount() never forces an unmount. When a process is using the path, unmount() throws S3_MOUNT_BUSY. Requests stay denied. Stop the process and call unmount() again, or call mount() with the same request to restore access. After denial, new storage requests for the mount receive HTTP 403. A request already in progress can complete. After denial, a process that still uses the path can read empty files and lose writes without an error. Listing a directory fails. A file that a process opened and read before the denial can stay readable through that open file. Canceling unmount() or mount() can leave the filesystem mounted with requests denied. Call mount() with the same request, or call unmount() again. Types S3MountRequest mountPath — absolute, normalized path to mount at. Cannot be /, and cannot overlap /proc/self/mountinfo, /run/sandbox/s3-mounts, or /usr/local/bin/sandbox-shim. source : type — source kind. endpoint — HTTP or HTTPS origin of the S3 API, with no credentials, path, query, or fragment. For R2, use https://.r2.cloudflarestorage.com. region — region used to sign requests. For R2, use auto. bucket — bucket name. Cannot start with - or contain / or :. credentials — static credentials or a credential provider. Refer to Credentials. keyPrefix — object key prefix to mount. The mount shows only keys under the prefix. The prefix cannot start with /. S3Mount adds a trailing /. Omit it to mount the whole bucket. access — whether processes in the container can create, change, and delete objects. s3fsOptions — extra s3fs options. For the options, refer to the s3fs manual. A true value adds the option as a flag, false omits it, and a string or number adds name=value. Options that set the endpoint, credentials, access mode, proxy, or filesystem identity throw TypeError. S3Mount always sets nomixupload. Passing nomixupload throws TypeError. With nomixupload, a command that changes part of a large file uploads the whole file in parts of one size, as R2 requires. S3Mount sets no cache, retry, or timeout options. The defaults of the s3fs version in your image apply. S3Mount does not accept an R2 binding. Use the R2 S3 API endpoint with R2 API credentials. Credentials Static credentials: type accessKeyId secretAccessKey sessionToken Provider credentials: type fetcher — called for each storage request that S3Gateway allows. S3Gateway sends the provider GET https://credentials.sandbox.internal/ with Accept: application/json. The response must be JSON of at most 16 KiB: { "accessKeyId": "", "secretAccessKey": "", "sessionToken": "", "expiresAt": 1790000000000 } sessionToken is optional. expiresAt is a Unix time in milliseconds and must be in the future. S3Gateway does not cache credentials and does not retry a failed provider request. S3MountOperationOptions signal — cancels the operation. The operation throws the abort reason. S3MountInspection Every inspection has mountPath and attachment.status: attachment.status Other fields Meaning absent None No mount at the path unmanaged attachment.filesystemType Another filesystem is mounted at the path incompatible None The path has state from an unsupported version stale attachment.configuration, gateway Mount settings remain, but the filesystem is gone managed attachment.configuration, fuse, gateway The mount is active configuration — the endpoint, region, bucket, key prefix, access mode, and s3fs options of the mount. fuse.status gateway.status gateway.upstream.status — present when gateway.status is reachable. S3Gateway S3Gateway is a WorkerEntrypoint that handles storage requests from the container. S3Mount connects a separate S3Gateway to each mount. The container receives placeholder credentials, because s3fs requires a key pair. S3Gateway does not forward the Authorization header from the container. It signs each allowed request with the credentials of the mount. S3Gateway allows only the object and list operations that s3fs uses, within the bucket and prefix of the mount. Other requests are rejected. A read-only mount allows no writes. Any process in the container that can reach the mount path can perform every allowed operation on the prefix. Limit the credentials to the bucket and prefix as well. Mounted file behavior A mounted directory maps files to objects in the bucket. It does not behave like a local disk: Renaming a file copies the object and deletes the original. File locks, hard links, ownership, permissions, and atomic replacement do not work as they do on a local filesystem. Directories come from object key prefixes. s3fs caches file metadata, including the fact that a file does not exist, for 900 seconds by default. Until the cache expires, a file that another client adds to the bucket can stay missing in the container. Until the cache expires, a file that another client changes can keep its old size, and a read can return the new content cut to that size. To shorten the wait, set a lower stat_cache_expire in s3fsOptions. To stop caching missing files, set disable_noobj_cache: true. An interrupted write can leave an incomplete upload in the bucket. A snapshot does not include mounted directories. Errors SandboxS3MountError A mount operation failed. Field Type Description name "SandboxS3MountError" Error name code SandboxS3MountErrorCode Error code operation S3MountOperation Method that failed: mount, inspect, or unmount path string Path passed to the method detail string Error description from the helper code is one of the following values: Code Meaning S3_MOUNT_CONFLICT Another filesystem, or a mount with different settings, owns the path S3_MOUNT_BUSY A process is using the path S3_MOUNT_FAILED s3fs, FUSE, or another step of the operation failed S3_MOUNT_INCOMPATIBLE The path has state from an unsupported version The package exports the SandboxS3MountErrorCode and S3MountOperation types, which list these codes and methods. SandboxS3MountError is not a class. Use SandboxS3MountError.is(error) instead of instanceof. It recognizes errors thrown in the same Worker and errors returned through Durable Object RPC. SandboxProtocolError S3Mount could not complete its exchange with the helper binary. Refer to SandboxProtocolError. Other errors S3Mount does not wrap errors from the runtime or from its inputs. Condition Error Invalid request or path TypeError The container is not running Error from exec() signal is aborted The abort reason An intercept cannot be added Error from interceptOutboundHttp() Related resources Mount an R2 bucket Durable Object Container Sandbox security Getting started import { Render, PackageManagers, Steps, WranglerConfig } from "~/components"; Build your first application with Sandbox SDK - a secure code execution environment. In this guide, you'll create a Worker that can execute Python code and work with files in isolated containers. :::note[What you're building] A simple API that can safely execute Python code and perform file operations in isolated sandbox environments. ::: Prerequisites Ensure Docker is running locally Sandbox SDK uses Docker to build container images alongside your Worker. 1. Create a new project Create a new Sandbox SDK project: This creates a my-sandbox directory with everything you need: src/index.ts - Worker with sandbox integration wrangler.jsonc - Configuration for Workers and Containers Dockerfile - Container environment definition cd my-sandbox 2. Explore the template The template provides a minimal Worker that demonstrates core sandbox capabilities: import { getSandbox, proxyToSandbox, type Sandbox } from "@cloudflare/sandbox"; export { Sandbox } from "@cloudflare/sandbox"; type Env = { Sandbox: DurableObjectNamespace; }; export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); // Get or create a sandbox instance. For user-facing apps, // derive this ID from the authenticated user. const sandbox = getSandbox(env.Sandbox, "my-sandbox"); // Execute Python code if (url.pathname === "/run") { const result = await sandbox.exec('python3 -c "print(2 + 2)"'); return Response.json({ output: result.stdout, error: result.stderr, exitCode: result.exitCode, success: result.success, }); } // Work with files if (url.pathname === "/file") { await sandbox.writeFile("/workspace/hello.txt", "Hello, Sandbox!"); const file = await sandbox.readFile("/workspace/hello.txt"); return Response.json({ content: file.content, }); } return new Response("Try /run or /file"); }, }; Key concepts: getSandbox() - Gets or creates a sandbox instance by ID. Use a stable ID to reconnect to the same sandbox. In user-facing apps, scope IDs to a single user. sandbox.exec() - Execute shell commands in the sandbox and capture stdout, stderr, and exit codes. sandbox.writeFile() / readFile() - Write and read files in the sandbox filesystem. 3. Test locally Start the development server: npm run dev # If you expect to have multiple sandbox instances, you can increase `max_instances`. :::note First run builds the Docker container (2-3 minutes). Subsequent runs are much faster due to caching. ::: Test the endpoints: # Execute Python code curl http://localhost:8787/run # File operations curl http://localhost:8787/file You should see JSON responses with the command output and file contents. 4. Deploy to production Deploy your Worker and container: npx wrangler deploy This will: Build your container image using Docker Push it to Cloudflare's Container Registry Deploy your Worker globally :::caution[Wait for provisioning] After the first deployment, wait several minutes before you expect sandbox requests to succeed. The Worker deploys immediately, but the container image still has to provision. ::: Check deployment status: npx wrangler containers list 5. Test your deployment Visit your Worker URL (shown in deploy output): # Replace with your actual URL curl https://my-sandbox.YOUR_SUBDOMAIN.workers.dev/run Your sandbox is now deployed and can execute code in isolated containers. Understanding the configuration Your wrangler.jsonc connects three pieces together: { "containers": [ { "class_name": "Sandbox", "image": "./Dockerfile", "instance_type": "lite", "max_instances": 1, }, ], "durable_objects": { "bindings": [ { "class_name": "Sandbox", "name": "Sandbox", }, ], }, "migrations": [ { "new_sqlite_classes": ["Sandbox"], "tag": "v1", }, ], } containers - Defines the container image, instance type, and resource limits for your sandbox environment. If you expect to have multiple sandbox instances, you can increase max_instances. durable_objects - You need not be familiar with Durable Objects to use Sandbox SDK, but if you'd like, you can learn more about Cloudflare Containers and Durable Objects. This configuration creates a binding that makes the Sandbox Durable Object accessible in your Worker code. migrations - Registers the Sandbox class, implemented by the Sandbox SDK, with SQLite storage backend (required once) For detailed configuration options including environment variables, secrets, and custom images, see the Wrangler configuration reference. Next steps Now that you have a working sandbox, explore more capabilities: Code interpreter with Workers AI - Build an AI-powered code execution system Execute commands - Run shell commands and stream output Manage files - Work with files and directories Deploy a Sandbox application - Deploy and keep package and image aligned Expose services - Get public URLs for services running in your sandbox Quick tunnels - Zero-config *.trycloudflare.com URLs for development and .workers.dev deployments Configure preview URLs on a custom domain - Wildcard DNS and TLS for exposePort() API reference - Complete API documentation Sandbox SDK 0.x import { CardGrid, LinkTitleCard, Plan } from "~/components"; :::note These pages document @cloudflare/sandbox 0.x for existing applications. Version 1.0 is a different library that works with a container you start from a Durable Object. To move an existing application to 1.0, refer to Migrate from Sandbox SDK 0.x. To start new work, refer to Sandboxes. ::: Sandbox SDK 0.x gives your Worker a Sandbox class built on Containers. A sandbox starts its container on first use, and its methods run commands, manage files, run background processes, and expose services in that container. Move to 1.0 Create point-in-time snapshots of sandbox directories and restore them from R2. For setup, restore workflows, and generated-cache exclusions, refer to Backup and restore. For overlay semantics, refer to Directory backups. Methods createBackup() Create a snapshot of a directory and upload it to R2. await sandbox.createBackup(options: BackupOptions): Promise Parameters: options - Backup configuration (see BackupOptions): dir (required) - Absolute path to back up. Must be under /workspace, /home, /tmp, /var/tmp, or /app. name (optional) - Human-readable name. Maximum 256 characters. Control characters are rejected. ttl (optional) - Time-to-live in seconds. Default: 259200 (3 days). Must be a positive number. gitignore (optional) - When true, exclude paths matching .gitignore rules if dir is inside a git repository. Default: false. If the directory is not in a git repository, no git exclusions apply. If git is not installed, the SDK logs a warning and continues without git-based exclusions. excludes (optional) - Glob patterns to omit from the archive. Passed to mksquashfs as wildcard excludes. ** globstars are normalized automatically. Default: []. localBucket (optional) - When true, use the BACKUP_BUCKET R2 binding instead of presigned URLs. Intended for wrangler dev. Default: false. compression (optional) - Archive compression. Default format: lz4. Default threads: 8. Format must be gzip, lz4, or zstd. threads must be a positive integer. multipart (optional) - Use parallel multipart upload for large archives. Default: true. Returns: Promise containing: id - Unique backup identifier (UUID) dir - Directory that was backed up localBucket (optional) - Whether the backup used local R2 binding mode import { getSandbox } from "@cloudflare/sandbox"; const sandbox = getSandbox(env.Sandbox, "my-sandbox"); const backup = await sandbox.createBackup({ dir: "/workspace" }); await sandbox.restoreBackup(backup); How it works: In production: The container creates a compressed squashfs archive. The container uploads the archive to R2 with a presigned URL. Metadata is stored alongside the archive in R2. The local archive is deleted. With localBucket: true: The container creates a compressed squashfs archive. The archive is uploaded through the BACKUP_BUCKET R2 binding. Metadata is stored alongside the archive in R2. The local archive is deleted. Throws: InvalidBackupConfigError - If dir is not an allowed absolute path, the BACKUP_BUCKET binding is missing, or (in production) R2 presigned URL credentials are not configured BackupCreateError - If archive creation or the upload to R2 fails :::note[R2 binding required] Configure a BACKUP_BUCKET R2 binding in wrangler.jsonc before using backup methods. Production also requires R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID, and BACKUP_BUCKET_NAME. Refer to Backup and restore. ::: :::caution[Path permissions] mksquashfs must read every file and subdirectory in dir. Restrictive permissions fail with BackupCreateError. Refer to Fix path permissions. ::: :::caution[Partial writes] Partially written files may not be captured consistently. Completed writes are included. ::: restoreBackup() Restore a previously created backup. await sandbox.restoreBackup(backup: DirectoryBackup): Promise Parameters: backup - Handle returned by createBackup(). Contains id and dir. Restore writes into backup.dir, which may differ from the original backup path. (see DirectoryBackup) Returns: Promise containing: success - Whether the restore succeeded dir - Directory that was restored id - Backup ID that was restored await sandbox.restoreBackup(backup); How it works: In production: Metadata is downloaded from R2 and the TTL is checked, with a 60-second buffer. An expired backup throws. The container downloads the archive from R2 with a presigned URL. The container mounts the archive with FUSE overlayfs. With localBucket: true: Metadata is downloaded from the BACKUP_BUCKET binding and the TTL is checked. The archive is downloaded from the R2 binding. The archive is extracted with unsquashfs. Throws: InvalidBackupConfigError - If backup.id is missing or not a UUID, or backup.dir is invalid BackupNotFoundError - If the metadata or archive is not in R2 BackupExpiredError - If the TTL has elapsed BackupRestoreError - If the container fails to restore :::note[Copy-on-write] In production, the backup is a read-only lower layer and new writes go to a writable upper layer. In local development, the directory is replaced. For overlay constraints, refer to Directory backups. ::: :::caution[Ephemeral mount] In production, the FUSE mount is lost when the sandbox sleeps or restarts. Restore again from the handle. Stop processes that write to the target directory before restoring. ::: Behavior Concurrent backup and restore operations on the same sandbox are serialized. DirectoryBackup is serializable. Store it in KV, D1, or Durable Object storage. Overlapping backups are independent. Restoring a parent directory overwrites subdirectory mounts. Restore the parent first when restoring both. ttl is enforced at restore time only. Expired objects remain in R2 until you delete them or an R2 lifecycle rule removes them. Backup objects use backups/{id}/data.sqsh and backups/{id}/meta.json. Types BackupOptions interface BackupCompressionOptions { format?: "gzip" | "lz4" | "zstd"; threads?: number; } interface BackupOptions { dir: string; name?: string; ttl?: number; gitignore?: boolean; excludes?: string[]; localBucket?: boolean; compression?: BackupCompressionOptions; multipart?: boolean; } Fields: dir (required) - Absolute path under /workspace, /home, /tmp, /var/tmp, or /app name (optional) - Human-readable name. Maximum 256 characters. No control characters. ttl (optional) - Time-to-live in seconds. Default: 259200 (3 days). Must be a positive number. gitignore (optional) - When true, exclude .gitignore matches if dir is inside a git repository. Default: false. excludes (optional) - Glob patterns to omit. Example: ['node_modules/.cache', '*.log']. Refer to Exclude generated caches. localBucket (optional) - Use the BACKUP_BUCKET binding instead of presigned URLs. Default: false. compression (optional) - format defaults to lz4. threads defaults to 8. multipart (optional) - Parallel multipart upload. Default: true. DirectoryBackup interface DirectoryBackup { readonly id: string; readonly dir: string; readonly localBucket?: boolean; } Fields: id - Unique backup identifier (UUID) dir - Directory to restore into localBucket (optional) - Whether the backup used local R2 binding mode RestoreBackupResult interface RestoreBackupResult { success: boolean; dir: string; id: string; } Fields: success - Whether the restore succeeded dir - Directory that was restored id - Backup ID that was restored Related resources Backup and restore - Setup and restore workflows Directory backups - Overlay restore and EXDEV Storage API - Mount S3-compatible buckets Files API - Read and write files Wrangler configuration - Configure bindings Commands import { Details, TypeScriptExample, Render } from "~/components"; Execute commands and manage background processes in the sandbox's isolated container environment. Methods exec() Execute a command and return the complete result. const result = await sandbox.exec(command: string, options?: ExecOptions): Promise Parameters: command - The command to execute (can include arguments) options (optional): stream - Enable streaming callbacks (default: false) onOutput - Callback for real-time output: (stream: 'stdout' | 'stderr', data: string) => void timeout - Maximum execution time in milliseconds env - Environment variables for this command: Record cwd - Working directory for this command stdin - Data to pass to the command's standard input (enables arbitrary input without shell injection risks) Returns: Promise with success, stdout, stderr, exitCode ``` const result = await sandbox.exec('npm run build'); if (result.success) { console.log('Build output:', result.stdout); } else { console.error('Build failed:', result.stderr); } // With streaming await sandbox.exec('npm install', { stream: true, onOutput: (stream, data) => console.log( [${stream}] ${data}) }); // With environment variables (undefined values are skipped) await sandbox.exec('node app.js', { env: { NODE_ENV: 'production', PORT: '3000', DEBUG_MODE: undefined // Skipped, uses container default or unset } }); // Pass input via stdin (no shell injection risks) const result = await sandbox.exec('cat', { stdin: 'Hello, world!' }); console.log(result.stdout); // "Hello, world!" // Process user input safely const userInput = 'user@example.com\nsecret123'; await sandbox.exec('python process_login.py', { stdin: userInput }); :::note[Timeout behavior] When a command times out, the SDK raises an error on the caller side and closes the connection. The underlying process **continues running** inside the container. To stop a timed-out process, delete the session with [`deleteSession()`](/sandbox/sdk/api/sessions/#deletesession) or destroy the sandbox with [`destroy()`](/sandbox/sdk/api/lifecycle/#destroy). Timeout precedence: per-command `timeout` on `exec()` > session-level `commandTimeoutMs` on [`createSession()`](/sandbox/sdk/api/sessions/#createsession) > global [`COMMAND_TIMEOUT_MS`](/sandbox/sdk/configuration/environment-variables/#command_timeout_ms) environment variable. If none are set, commands run without a timeout. ::: ### `execStream()` Execute a command and return a Server-Sent Events stream for real-time processing. ```ts const stream = await sandbox.execStream(command: string, options?: ExecOptions): Promise Parameters: command - The command to execute options - Same as exec() (including stdin support) Returns: Promise emitting ExecEvent objects ( start, stdout, stderr, complete, error) ``` import { parseSSEStream, type ExecEvent } from '@cloudflare/sandbox'; const stream = await sandbox.execStream('npm run build'); for await (const event of parseSSEStream(stream)) { switch (event.type) { case 'stdout': console.log('Output:', event.data); break; case 'complete': console.log('Exit code:', event.exitCode); break; case 'error': console.error('Failed:', event.error); break; } } // Stream with stdin input const inputStream = await sandbox.execStream('python -c "import sys; print(sys.stdin.read())"', { stdin: 'Data from Workers!' }); for await (const event of parseSSEStream(inputStream)) { if (event.type === 'stdout') { console.log('Python received:', event.data); } } ### `startProcess()` Start a long-running background process. ```ts const process = await sandbox.startProcess(command: string, options?: ProcessOptions): Promise Parameters: command - The command to start as a background process options (optional): cwd - Working directory env - Environment variables: Record stdin - Data to pass to the command's standard input timeout - Maximum execution time in milliseconds processId - Custom process ID encoding - Output encoding (default: 'utf8') autoCleanup - Whether to clean up process on sandbox sleep Returns: Promise object with: id - Unique process identifier pid - System process ID command - The command being executed status - Current status ( 'running', 'exited', etc.) kill() - Stop the process getStatus() - Get current status getLogs() - Get accumulated logs waitForPort() - Wait for process to listen on a port waitForLog() - Wait for pattern in process output waitForExit() - Wait for process to terminate and return exit code ``` const server = await sandbox.startProcess('python -m http.server 8000'); console.log('Started with PID:', server.pid); // With custom environment const app = await sandbox.startProcess('node app.js', { cwd: '/workspace/my-app', env: { NODE_ENV: 'production', PORT: '3000' } }); // Start process with stdin input (useful for interactive applications) const interactive = await sandbox.startProcess('python interactive_app.py', { stdin: 'initial_config\nstart_mode\n' }); ### `listProcesses()` List all running processes. ```ts const processes = await sandbox.listProcesses(): Promise ``` const processes = await sandbox.listProcesses(); for (const proc of processes) { console.log( ${proc.id}: ${proc.command} (PID ${proc.pid})); } ### `killProcess()` Terminate a specific process and all of its child processes. ```ts await sandbox.killProcess(processId: string, signal?: string): Promise Parameters: processId - The process ID (from startProcess() or listProcesses()) signal - Signal to send (default: "SIGTERM") Sends the signal to the entire process group, ensuring that both the main process and any child processes it spawned are terminated. This prevents orphaned processes from continuing to run after the parent is killed. ``` const server = await sandbox.startProcess('python -m http.server 8000'); await sandbox.killProcess(server.id); // Example with a process that spawns children const script = await sandbox.startProcess('bash -c "sleep 10 & sleep 10 & wait"'); // killProcess terminates both sleep commands and the bash process await sandbox.killProcess(script.id); ### `killAllProcesses()` Terminate all running processes. ```ts await sandbox.killAllProcesses(): Promise ``` await sandbox.killAllProcesses(); ``` streamProcessLogs() Stream logs from a running process in real-time. const stream = await sandbox.streamProcessLogs(processId: string): Promise Parameters: processId - The process ID Returns: Promise emitting LogEvent objects ``` import { parseSSEStream, type LogEvent } from '@cloudflare/sandbox'; const server = await sandbox.startProcess('node server.js'); const logStream = await sandbox.streamProcessLogs(server.id); for await (const log of parseSSEStream(logStream)) { console.log( [${log.timestamp}] ${log.data}); if (log.data.includes('Server started')) break; } ### `getProcessLogs()` Get accumulated logs from a process. ```ts const logs = await sandbox.getProcessLogs(processId: string): Promise Parameters: processId - The process ID Returns: Promise with all accumulated output ``` const server = await sandbox.startProcess('node server.js'); await new Promise(resolve => setTimeout(resolve, 5000)); const logs = await sandbox.getProcessLogs(server.id); console.log('Server logs:', logs); ## Standard input (stdin) All command execution methods support passing data to a command's standard input via the `stdin` option. This enables secure processing of user input without shell injection risks. ### How stdin works When you provide the `stdin` option: 1. The input data is written to a temporary file inside the container 2. The command receives this data through its standard input stream 3. The temporary file is automatically cleaned up after execution This approach prevents shell injection attacks that could occur when embedding user data directly in commands. // Safe: User input goes through stdin, not shell parsing const userInput = 'user@domain.com; rm -rf /'; const result = await sandbox.exec('python validate_email.py', { stdin: userInput }); // Instead of unsafe: python validate_email.py "${userInput}" // which could execute the embedded rm -rf / command ### Common patterns **Processing form data:** const formData = JSON.stringify({ username: 'john_doe', email: 'john@example.com' }); const result = await sandbox.exec('python process_form.py', { stdin: formData }); **Interactive command-line tools:** // Simulate user responses to prompts const responses = 'yes\nmy-app\n1.0.0\n'; const result = await sandbox.exec('npm init', { stdin: responses }); **Data transformation:** const csvData = 'name,age,city\nJohn,30,NYC\nJane,25,LA'; const result = await sandbox.exec('python csv_processor.py', { stdin: csvData }); console.log('Processed data:', result.stdout); ## Process readiness methods The `Process` object returned by `startProcess()` includes methods to wait for the process to be ready before proceeding. ### `process.waitForPort()` Wait for a process to listen on a port. ```ts await process.waitForPort(port: number, options?: WaitForPortOptions): Promise Parameters: port - The port number to check options (optional): mode - Check mode: 'http' (default) or 'tcp' timeout - Maximum wait time in milliseconds interval - Check interval in milliseconds (default: 100) path - HTTP path to check (default: '/', HTTP mode only) status - Expected HTTP status range (default: { min: 200, max: 399 }, HTTP mode only) HTTP mode (default) makes an HTTP GET request and checks the response status: ``` const server = await sandbox.startProcess('node server.js'); // Wait for server to be ready (HTTP mode) await server.waitForPort(3000); // Check specific endpoint and status await server.waitForPort(8080, { path: '/health', status: { min: 200, max: 299 }, timeout: 30000 }); **TCP mode** checks if the port accepts connections: const db = await sandbox.startProcess('redis-server'); // Wait for database to accept connections await db.waitForPort(6379, { mode: 'tcp', timeout: 10000 }); **Throws**: - `ProcessReadyTimeoutError` - If port does not become ready within timeout - `ProcessExitedBeforeReadyError` - If process exits before becoming ready ### `process.waitForLog()` Wait for a pattern to appear in process output. ```ts const result = await process.waitForLog(pattern: string | RegExp, timeout?: number): Promise Parameters: pattern - String or RegExp to match in stdout/stderr timeout - Maximum wait time in milliseconds (optional) Returns: Promise with: line - The matching line of output matches - Array of capture groups (for RegExp patterns) ``` const server = await sandbox.startProcess('node server.js'); // Wait for string pattern const result = await server.waitForLog('Server listening'); console.log('Ready:', result.line); // Wait for RegExp with capture groups const result = await server.waitForLog(/Server listening on port (\d+)/); console.log('Port:', result.matches[1]); // Extracted port number // With timeout await server.waitForLog('Ready', 30000); **Throws**: - `ProcessReadyTimeoutError` - If pattern is not found within timeout - `ProcessExitedBeforeReadyError` - If process exits before pattern appears ### `process.waitForExit()` Wait for a process to terminate and return the exit code. ```ts const result = await process.waitForExit(timeout?: number): Promise Parameters: timeout - Maximum wait time in milliseconds (optional) Returns: Promise with: exitCode - The process exit code ``` const build = await sandbox.startProcess('npm run build'); // Wait for build to complete const result = await build.waitForExit(); console.log('Build finished with exit code:', result.exitCode); // With timeout const result = await build.waitForExit(60000); // 60 second timeout **Throws**: - `ProcessReadyTimeoutError` - If process does not exit within timeout ## Related resources - [Background processes guide](/sandbox/sdk/guides/background-processes/) - Managing long-running processes - [Files API](/sandbox/sdk/api/files/) - File operations File watching import { TypeScriptExample, Render } from "~/components"; Monitor filesystem changes in real-time using Linux's native inotify system. The watch() method returns a Server-Sent Events (SSE) stream of file change events that you consume with parseSSEStream(). Methods watch() Watch a directory for filesystem changes. Returns an SSE stream of events. const stream = await sandbox.watch(path: string, options?: WatchOptions): Promise> Parameters: path - Absolute path or relative to /workspace (for example, /app/src or src) options (optional): recursive - Watch subdirectories recursively (default: true) include - Glob patterns to include (for example, ['*.ts', '*.js']). Cannot be used together with exclude. exclude - Glob patterns to exclude (default: ['.git', 'node_modules', '.DS_Store']). Cannot be used together with include. sessionId - Session to run the watch in (if omittied, will use the default session unless enableDefaultSession is set to false) Returns: Promise> — an SSE stream of FileWatchSSEEvent objects import { parseSSEStream } from "@cloudflare/sandbox"; import type { FileWatchSSEEvent } from "@cloudflare/sandbox"; const stream = await sandbox.watch("/workspace/src", { recursive: true, include: ["*.ts", "*.js"], }); const controller = new AbortController(); for await (const event of parseSSEStream( stream, controller.signal, )) { switch (event.type) { case "watching": console.log(`Watch established on ${event.path} (id: ${event.watchId})`); break; case "event": console.log(`${event.eventType}: ${event.path}`); break; case "error": console.error(`Watch error: ${event.error}`); break; case "stopped": console.log(`Watch stopped: ${event.reason}`); break; } } // Cancel the watch by aborting — cleans up the watcher server-side controller.abort(); :::note The watch() method is also available on sessions. When called on a session, the sessionId is set automatically: const session = await sandbox.createSession(); const stream = await session.watch("/workspace/src", { include: ["*.ts"], }); ::: Types FileWatchSSEEvent Union type of all SSE events emitted by the watch stream. type FileWatchSSEEvent = | { type: "watching"; path: string; watchId: string } | { type: "event"; eventType: FileWatchEventType; path: string; isDirectory: boolean; timestamp: string; } | { type: "error"; error: string } | { type: "stopped"; reason: string }; watching — Emitted once when the watch is established. Contains the watchId and the path being watched. event — Emitted for each filesystem change. Contains the eventType, the path that changed, and whether it isDirectory. error — Emitted when the watch encounters an error. stopped — Emitted when the watch is stopped, with a reason. FileWatchEventType Types of filesystem changes that can be detected. type FileWatchEventType = | "create" | "modify" | "delete" | "move_from" | "move_to" | "attrib"; create — File or directory was created modify — File content changed delete — File or directory was deleted move_from — File or directory was moved away (source of a rename/move) move_to — File or directory was moved here (destination of a rename/move) attrib — File or directory attributes changed (permissions, timestamps) WatchOptions Configuration options for watching directories. interface WatchOptions { /** Watch subdirectories recursively (default: true) */ recursive?: boolean; /** Glob patterns to include. Cannot be used together with `exclude`. */ include?: string[]; /** Glob patterns to exclude. Cannot be used together with `include`. Default: ['.git', 'node_modules', '.DS_Store'] */ exclude?: string[]; /** Session to run the watch in. If omitted, the sandbox's implicit execution mode is used. */ sessionId?: string; } :::caution[Mutual exclusivity] include and exclude cannot be used together. Use include to allowlist patterns, or exclude to blocklist patterns. Requests that specify both are rejected with a validation error. ::: parseSSEStream() Converts a ReadableStream into a typed AsyncGenerator of events. Accepts an optional AbortSignal to cancel the stream. function parseSSEStream( stream: ReadableStream, signal?: AbortSignal, ): AsyncGenerator; Parameters: stream — The SSE stream returned by watch() signal (optional) — An AbortSignal to cancel the stream. When aborted, the reader is cancelled which propagates cleanup to the server. Aborting the signal is the recommended way to stop a watch from outside the consuming loop: const controller = new AbortController(); // Cancel after 60 seconds setTimeout(() => controller.abort(), 60_000); for await (const event of parseSSEStream( stream, controller.signal, )) { // process events } Glob pattern support The include and exclude options accept a limited set of glob tokens for predictable matching: Token Meaning Example * Match any characters within a path segment *.ts matches index.ts ** Match across directory boundaries **/*.test.ts ? Match a single character ?.js matches a.js Character classes ( [abc]), brace expansion ( {a,b}), and backslash escapes are not supported. Patterns containing these tokens are rejected with a validation error. Notes :::note[Deterministic readiness] watch() blocks until the filesystem watcher is established on the server. When the promise resolves, the watcher is active and you can immediately perform filesystem actions that depend on the watch being in place. ::: :::note[Container lifecycle] File watchers are automatically stopped when the sandbox container sleeps or is destroyed. You do not need to manually cancel the stream on container shutdown. ::: :::caution[Path requirements] All paths must exist when starting a watch. Watching non-existent paths returns an error. Create directories before watching them. All paths must resolve to within /workspace. ::: Related resources Watch filesystem changes guide — Patterns, best practices, and real-world examples Manage files guide — File operations Files import { TypeScriptExample, Render } from "~/components"; Read, write, and manage files in the sandbox filesystem. All paths are absolute (e.g., /workspace/app.js). Methods writeFile() Write content to a file. await sandbox.writeFile(path: string, content: string, options?: WriteFileOptions): Promise Parameters: path - Absolute path to the file content - Content to write options (optional): encoding - File encoding ( "utf-8" or "base64", default: "utf-8") ``` await sandbox.writeFile('/workspace/app.js', `console.log('Hello!');`); // Binary data await sandbox.writeFile('/tmp/image.png', base64Data, { encoding: 'base64' }); :::note[Base64 validation] When using `encoding: 'base64'`, content must contain only valid base64 characters (A-Z, a-z, 0-9, +, /, =). Invalid base64 content returns a validation error. ::: #### Large files and binary data When using the [`rpc` transport](/sandbox/sdk/configuration/transport/) the `writeFile()` method supports passing a `ReadableStream` as the `content` parameter. This allows binary data and files greater than [32 MiB](/workers/runtime-apis/rpc/#limitations) to be written to the sandbox. It replaces the `"base64"` encoding option. ```js // Requires SANDBOX_TRANSPORT to be "rpc" in wrangler.jsonc const req = await fetch("https://example.com/archive.tar.gz"); await sandbox.writeFile('/workspace/archive.tar.gz', req.body); readFile() Read a file from the sandbox. By default returns the content as a string. This is useful for small text files. For larger files and binary data use encoding: "none" to get back a ReadableStream with the file data. const file = await sandbox.readFile(path: string, options?: ReadFileOptions): Promise Parameters: path - Absolute path to the file options (optional): encoding - File encoding ( "utf-8", "base64" or "none", default: auto-detected from MIME type) Returns: Promise. :::note[Encoding] The "none" encoding property was added in 0.10.1 and aims to improve support for streaming binary data. When encoding: "none" is provided the content field will be a ReadableStream. It is only supported with the RPC transport. ::: ``` const file = await sandbox.readFile('/workspace/package.json'); const pkg = JSON.parse(file.content); // Binary data (since 0.10.1 using rpc transport) const { content, size, mimeType } = await sandbox.readFile("/workspace/archive.tar.gz", { encoding: "none" }); // Example 1: Store on R2: const stream = request.body.pipeThrough(new FixedLengthStream(size)); await env.MY_BUCKET.put('/bucket/archive.tar.gz', stream, { httpMetadata: { contentType: mimeType } }); // Example 2: Stream an HTTP response: return new Response(content, { headers: { "Content-Type": mimeType } }); // Older versions/transports used the base64 encoding for binary data: const archive = await sandbox.readFile("/workspace/archive.tar.gz", { encoding: "base64" }); console.log(archive.content); // => ""; :::note[Encoding behavior] When `encoding` is specified, it overrides MIME-based auto-detection. Without `encoding`, the SDK detects the appropriate encoding from the file's MIME type. ::: ### `exists()` Check if a file or directory exists. ```ts const result = await sandbox.exists(path: string): Promise Parameters: path - Absolute path to check Returns: Promise with exists boolean ``` const result = await sandbox.exists('/workspace/package.json'); if (result.exists) { const file = await sandbox.readFile('/workspace/package.json'); // process file } // Check directory const dirResult = await sandbox.exists('/workspace/src'); if (!dirResult.exists) { await sandbox.mkdir('/workspace/src'); } :::note[Available on sessions] Both `sandbox.exists()` and `session.exists()` are supported. ::: ### `mkdir()` Create a directory. ```ts await sandbox.mkdir(path: string, options?: MkdirOptions): Promise Parameters: path - Absolute path to the directory options (optional): recursive - Create parent directories if needed (default: false) ``` await sandbox.mkdir('/workspace/src'); // Nested directories await sandbox.mkdir('/workspace/src/components/ui', { recursive: true }); ### `deleteFile()` Delete a file. ```ts await sandbox.deleteFile(path: string): Promise Parameters: path - Absolute path to the file ``` await sandbox.deleteFile('/workspace/temp.txt'); ``` renameFile() Rename a file. await sandbox.renameFile(oldPath: string, newPath: string): Promise Parameters: oldPath - Current file path newPath - New file path ``` await sandbox.renameFile('/workspace/draft.txt', '/workspace/final.txt'); ``` moveFile() Move a file to a different directory. await sandbox.moveFile(sourcePath: string, destinationPath: string): Promise Parameters: sourcePath - Current file path destinationPath - Destination path ``` await sandbox.moveFile('/tmp/download.txt', '/workspace/data.txt'); ``` gitCheckout() Clone a git repository. await sandbox.gitCheckout(repoUrl: string, options?: GitCheckoutOptions): Promise Parameters: repoUrl - Git repository URL options (optional): branch - Branch to checkout (default: repository default branch) targetDir - Directory to clone into (default: /workspace/{repoName}) depth - Clone depth for shallow clones (e.g., 1 for latest commit only) ``` await sandbox.gitCheckout('https://github.com/user/repo'); // Specific branch await sandbox.gitCheckout('https://github.com/user/repo', { branch: 'develop', targetDir: '/workspace/my-project' }); // Shallow clone (faster for large repositories) await sandbox.gitCheckout('https://github.com/facebook/react', { depth: 1 }); ## Related resources - [Manage files guide](/sandbox/sdk/guides/manage-files/) - Detailed guide with best practices - [Commands API](/sandbox/sdk/api/commands/) - Execute commands API reference import { LinkTitleCard, CardGrid, Render } from "~/components"; The Sandbox SDK provides a comprehensive API for executing code, managing files, running processes, and exposing services in isolated sandboxes. Create and manage sandbox containers. Get sandbox instances, configure options, and clean up resources. Execute commands and stream output. Run scripts, manage background processes, and capture execution results. Read, write, and manage files in the sandbox filesystem. Includes directory operations and file metadata. Execute Python, JavaScript, and TypeScript code with support for data visualizations, tables, and rich output formats. Contexts maintain state (variables, imports, functions) across executions. Methods createCodeContext() Create a persistent execution context for running code. const context = await sandbox.createCodeContext(options?: CreateContextOptions): Promise Parameters: options (optional): language - "python" | "javascript" | "typescript" (default: "python") cwd - Working directory (default: "/workspace") envVars - Environment variables timeout - Request timeout in milliseconds (default: 30000) Returns: Promise with id, language, cwd, createdAt, lastUsed ``` const ctx = await sandbox.createCodeContext({ language: 'python', envVars: { API_KEY: env.API_KEY } }); ``` runCode() Execute code in a context and return the complete result. const result = await sandbox.runCode(code: string, options?: RunCodeOptions): Promise Parameters: code - The code to execute (required) options (optional): context - Context to run in (recommended - see below) language - "python" | "javascript" | "typescript" (default: "python") timeout - Execution timeout in milliseconds (default: 60000) onStdout, onStderr, onResult, onError - Streaming callbacks Returns: Promise with: code - The executed code logs - stdout and stderr arrays results - Array of rich outputs (see Rich Output Formats) error - Execution error if any executionCount - Execution counter Recommended usage - create explicit context: ``` const ctx = await sandbox.createCodeContext({ language: 'python' }); await sandbox.runCode('import math; radius = 5', { context: ctx }); const result = await sandbox.runCode('math.pi * radius ** 2', { context: ctx }); console.log(result.results[0].text); // "78.53981633974483" :::note[Default context behavior] If no `context` is provided, a default context is automatically created/reused for the specified `language`. While convenient for quick tests, **explicitly creating contexts is recommended** for production use to maintain predictable state. const result = await sandbox.runCode( data = [1, 2, 3, 4, 5] print(f"Sum: {sum(data)}") sum(data), { language: 'python' }); console.log(result.logs.stdout); // ["Sum: 15"] console.log(result.results[0].text); // "15" ::: **Error handling**: const result = await sandbox.runCode('x = 1 / 0', { language: 'python' }); if (result.error) { console.error(result.error.name); // "ZeroDivisionError" console.error(result.error.value); // "division by zero" console.error(result.error.traceback); // Stack trace array } **JavaScript and TypeScript features**: JavaScript and TypeScript code execution supports top-level `await` and persistent variables across executions within the same context. const ctx = await sandbox.createCodeContext({ language: 'javascript' }); // Execution 1: Fetch data with top-level await await sandbox.runCode( const response = await fetch('https://api.example.com/data'); const data = await response.json();, { context: ctx }); // Execution 2: Use the data from previous execution const result = await sandbox.runCode('console.log(data)', { context: ctx }); console.log(result.logs.stdout); // Data persists across executions Variables declared with `const`, `let`, or `var` persist across executions, enabling multi-step workflows: const ctx = await sandbox.createCodeContext({ language: 'javascript' }); await sandbox.runCode('const x = 10', { context: ctx }); await sandbox.runCode('let y = 20', { context: ctx }); const result = await sandbox.runCode('x + y', { context: ctx }); console.log(result.results[0].text); // "30" ### `listCodeContexts()` List all active code execution contexts. ```ts const contexts = await sandbox.listCodeContexts(): Promise ``` const contexts = await sandbox.listCodeContexts(); console.log(`Found ${contexts.length} contexts`); ``` deleteCodeContext() Delete a code execution context and free its resources. await sandbox.deleteCodeContext(contextId: string): Promise ``` const ctx = await sandbox.createCodeContext({ language: 'python' }); await sandbox.runCode('print("Hello")', { context: ctx }); await sandbox.deleteCodeContext(ctx.id); ``` Rich Output Formats Results include: text, html, png, jpeg, svg, latex, markdown, json, chart, data Charts (matplotlib): ``` const result = await sandbox.runCode(` import matplotlib.pyplot as plt import numpy as np x = np.linspace(0, 10, 100) plt.plot(x, np.sin(x)) plt.show() `, { language: 'python' }); if (result.results[0]?.png) { const imageBuffer = Buffer.from(result.results[0].png, 'base64'); return new Response(imageBuffer, { headers: { 'Content-Type': 'image/png' } }); } **Tables (pandas)**: const result = await sandbox.runCode( import pandas as pd df = pd.DataFrame({'Name': ['Alice', 'Bob'], 'Age': [25, 30]}) df, { language: 'python' }); if (result.results[0]?.html) { return new Response(result.results[0].html, { headers: { 'Content-Type': 'text/html' } }); } ## Related resources - [Build an AI Code Executor](/sandbox/sdk/tutorials/ai-code-executor/) - Complete tutorial - [Commands API](/sandbox/sdk/api/commands/) - Lower-level command execution - [Files API](/sandbox/sdk/api/files/) - File operations Lifecycle import { TypeScriptExample, Render } from "~/components"; Create and manage sandbox containers. Get sandbox instances, configure options, and clean up resources. Methods getSandbox() Get or create a sandbox instance by ID. const sandbox = getSandbox( binding: DurableObjectNamespace, sandboxId: string, options?: SandboxOptions ): Sandbox Parameters: binding - The Durable Object namespace binding from your Worker environment sandboxId - Unique identifier for this sandbox. The same ID always returns the same sandbox instance. In user-facing apps, scope IDs to a single user. options (optional) - See SandboxOptions for all available options: enableDefaultSession - Use the default session for operations without an explicit sessionId. Set to false to evaluate each call in isolation (default: true) sleepAfter - Duration of inactivity before automatic sleep (default: "10m") keepAlive - Prevent automatic sleep entirely. Persists across hibernation (default: false) containerTimeouts - Configure container startup timeouts normalizeId - Lowercase sandbox IDs for preview URL compatibility (default: false) Returns: Sandbox instance :::note The container starts lazily on first operation. Calling getSandbox() returns immediately—the container only spins up when you execute a command, write a file, or perform other operations. See Sandbox lifecycle for details. ::: :::note[Implicit execution mode] By default, sandbox methods that do not specify a sessionId run in the sandbox's default session and preserve shell state between calls. It is recommended to set enableDefaultSession to false to ensure operations run in isolation. The createSession() API exists when sessions are required. Default sessions will be removed in a future version of the Sandbox SDK. ::: ``` import { getSandbox } from '@cloudflare/sandbox'; export default { async fetch(request: Request, env: Env): Promise { const sandbox = getSandbox(env.Sandbox, 'user-123'); const result = await sandbox.exec('python script.py'); return Response.json(result); } }; :::caution When using `keepAlive: true`, you **must** call `destroy()` when finished to prevent containers running indefinitely. ::: --- ### `setKeepAlive()` Enable or disable keepAlive mode dynamically after sandbox creation. ```ts await sandbox.setKeepAlive(keepAlive: boolean): Promise Parameters: keepAlive - true to prevent automatic sleep, false to allow normal sleep behavior When enabled, the sandbox automatically sends heartbeat pings every 30 seconds to prevent container eviction. When disabled, the sandbox returns to normal sleep behavior based on the sleepAfter configuration. ``` const sandbox = getSandbox(env.Sandbox, 'user-123'); // Enable keepAlive for a long-running process await sandbox.setKeepAlive(true); await sandbox.startProcess('python long_running_analysis.py'); // Later, disable keepAlive when done await sandbox.setKeepAlive(false); :::note[Heartbeat mechanism] When keepAlive is enabled, the sandbox automatically sends lightweight ping requests to the container every 30 seconds to prevent eviction. This happens transparently without affecting your application code. ::: :::caution[Resource management] Containers with `keepAlive: true` will not automatically timeout. Always disable keepAlive or call `destroy()` when done to prevent containers running indefinitely. ::: --- ### `destroy()` Destroy the sandbox container and free up resources. ```ts await sandbox.destroy(): Promise Immediately terminates the container and permanently deletes all state: All files in /workspace, /tmp, and /home All running processes All sessions (including the default session) Network connections and exposed ports ``` async function executeCode(code: string): Promise { const sandbox = getSandbox(env.Sandbox, `temp-${Date.now()}`); try { await sandbox.writeFile('/tmp/code.py', code); const result = await sandbox.exec('python /tmp/code.py'); return result.stdout; } finally { await sandbox.destroy(); } } :::note Containers automatically sleep after 10 minutes of inactivity but still count toward account limits. Use `destroy()` to immediately free up resources. ::: --- ## Related resources - [Sandbox lifecycle concept](/sandbox/sdk/concepts/sandboxes/) - Understanding container lifecycle and state - [Sandbox options configuration](/sandbox/sdk/configuration/sandbox-options/) - Configure `keepAlive` and other options - [Sessions API](/sandbox/sdk/api/sessions/) - Create execution contexts within a sandbox Ports import { TypeScriptExample, Render } from "~/components"; :::note[Production requires custom domain] Preview URLs require a custom domain with wildcard DNS routing in production. See Configure preview URLs on a custom domain. ::: :::note[Prefer sandbox.tunnels for public URLs] For most public-URL use cases — development, .workers.dev deployments, and production traffic — sandbox.tunnels is the recommended option. Use named tunnels for stable hostnames on a zone you control, or quick tunnels for zero-config *.trycloudflare.com URLs. exposePort() is appropriate when you want the Worker itself to front the request (for example, to inject authentication, rewrite responses, or call sandbox-only RPC methods on the same hostname). ::: Expose services running in your sandbox via public preview URLs. See Preview URLs concept for details. Module functions proxyToSandbox() Route incoming HTTP and WebSocket requests to the correct sandbox container. Call this at the top of your Worker's fetch handler, before any application logic, so that it intercepts and forwards preview URL requests automatically. proxyToSandbox(request: Request, env: Env): Promise Parameters: request - The incoming Request object from the fetch handler. env - The Env object containing your Sandbox binding. Returns: Promise — a Response if the request matched a preview URL and was routed to the sandbox, or null if the request did not match and should be handled by your application logic. The function inspects the request hostname to determine whether it matches the subdomain pattern of an exposed port (for example, 8080-sandbox-id-token.yourdomain.com). If it matches, proxyToSandbox() proxies the request to the correct Durable Object, and the sandbox service handles it. Both HTTP and WebSocket upgrade requests are supported. ```ts import { proxyToSandbox, getSandbox } from "@cloudflare/sandbox"; export { Sandbox } from "@cloudflare/sandbox"; export default { async fetch(request: Request, env: Env): Promise { // Always call proxyToSandbox first to handle preview URL requests const proxyResponse = await proxyToSandbox(request, env); if (proxyResponse) return proxyResponse; // Your application routes const sandbox = getSandbox(env.Sandbox, 'my-sandbox'); // ... return new Response('Not found', { status: 404 }); } }; :::note `proxyToSandbox` is a module-level function imported directly from `@cloudflare/sandbox` — it is not a method on a `Sandbox` instance. It requires the Sandbox Durable Object binding (`env.Sandbox`) to look up and route requests to the correct container. ::: ## Methods ### `exposePort()` Expose a port and get a preview URL for accessing services running in the sandbox. ```ts const response = await sandbox.exposePort(port: number, options: ExposePortOptions): Promise Parameters: port - Port number to expose (1024-65535) options: hostname - Your Worker's domain name (e.g., 'example.com'). Required to construct preview URLs with wildcard subdomains like https://8080-sandbox-abc123token.example.com. Cannot be a .workers.dev domain as it doesn't support wildcard DNS patterns. name - Friendly name for the port (optional) token - Custom token for the preview URL (optional). Must be 1-16 characters containing only lowercase letters (a-z), numbers (0-9), hyphens (-), and underscores (_). If not provided, a random 16-character token is generated automatically. Returns: Promise with port, url (preview URL), name ```ts // Extract hostname from request const { hostname } = new URL(request.url); // Basic usage with auto-generated token await sandbox.startProcess('python -m http.server 8000'); const exposed = await sandbox.exposePort(8000, { hostname }); console.log('Available at:', exposed.url); // https://8000-sandbox-id-abc123random.yourdomain.com // With custom token for stable URLs across restarts const stable = await sandbox.exposePort(8080, { hostname, token: 'my_service_v1' // 1-16 chars: a-z, 0-9, _ }); console.log('Stable URL:', stable.url); // https://8080-sandbox-id-my_service_v1.yourdomain.com // With custom token for stable URLs across deployments await sandbox.startProcess('node api.js'); const api = await sandbox.exposePort(3000, { hostname, name: 'api', token: 'prod-api-v1' // URL stays same across restarts }); console.log('Stable API URL:', api.url); // https://3000-sandbox-id-prod-api-v1.yourdomain.com // Multiple services with custom tokens await sandbox.startProcess('npm run dev'); const frontend = await sandbox.exposePort(5173, { hostname, name: 'frontend', token: 'dev-ui' }); :::note[Local development] When using `wrangler dev`, you must add `EXPOSE` directives to your Dockerfile for each port. See [Expose Services guide](/sandbox/sdk/guides/expose-services/#local-development) for details. ::: ## Custom Tokens for Stable URLs Custom tokens enable consistent preview URLs across container restarts and deployments. This is useful for: - **Production environments** - Share stable URLs with users or teams - **Development workflows** - Maintain bookmarks and integrations - **CI/CD pipelines** - Reference consistent URLs in tests or deployment scripts **Token Requirements:** - 1-16 characters in length - Only lowercase letters (a-z), numbers (0-9), hyphens (-), and underscores (_) - Must be unique per sandbox (cannot reuse tokens across different ports) ```ts // Production API with stable URL const { url } = await sandbox.exposePort(8080, { hostname: 'api.example.com', token: 'v1-stable' // Always the same URL }); // Error: Token collision prevention await sandbox.exposePort(8081, { hostname, token: 'v1-stable' }); // Throws: Token 'v1-stable' is already in use by port 8080 // Success: Re-exposing same port with same token (idempotent) await sandbox.exposePort(8080, { hostname, token: 'v1-stable' }); // Works - same port, same token validatePortToken() Validate if a token is authorized to access a specific exposed port. Useful for custom authentication or routing logic. const isValid = await sandbox.validatePortToken(port: number, token: string): Promise Parameters: port - Port number to check token - Token to validate Returns: Promise - true if token is valid for the port, false otherwise ```ts // Custom validation in your Worker export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); // Extract token from custom header or query param const customToken = request.headers.get('x-access-token'); if (customToken) { const sandbox = getSandbox(env.Sandbox, 'my-sandbox'); const isValid = await sandbox.validatePortToken(8080, customToken); if (!isValid) { return new Response('Invalid token', { status: 403 }); } } // Handle preview URL routing const proxyResponse = await proxyToSandbox(request, env); if (proxyResponse) return proxyResponse; // Your application routes return new Response('Not found', { status: 404 }); } }; ### `unexposePort()` Remove an exposed port and close its preview URL. ```ts await sandbox.unexposePort(port: number): Promise Parameters: port - Port number to unexpose ```ts await sandbox.unexposePort(8000); ``` getExposedPorts() Get information about all currently exposed ports. const response = await sandbox.getExposedPorts(): Promise Returns: Promise with ports array (containing port, url, name) ``` const { ports } = await sandbox.getExposedPorts(); for (const port of ports) { console.log( ${port.name || port.port}: ${port.url}); } ### `wsConnect()` Connect to WebSocket servers running in the sandbox. Use this when your Worker needs to establish WebSocket connections with services in the sandbox. **Common use cases:** - Route incoming WebSocket upgrade requests with custom authentication or authorization - Connect from your Worker to get real-time data from sandbox services For exposing WebSocket services via public preview URLs, use `exposePort()` with `proxyToSandbox()` instead. See [WebSocket Connections guide](/sandbox/sdk/guides/websocket-connections/) for examples. ```ts const response = await sandbox.wsConnect(request: Request, port: number): Promise Parameters: request - Incoming WebSocket upgrade request port - Port number (1024-65535, excluding 3000) Returns: Promise - WebSocket response establishing the connection ```ts import { getSandbox } from "@cloudflare/sandbox"; export { Sandbox } from "@cloudflare/sandbox"; export default { async fetch(request: Request, env: Env): Promise { if (request.headers.get('Upgrade')?.toLowerCase() === 'websocket') { const sandbox = getSandbox(env.Sandbox, 'my-sandbox'); return await sandbox.wsConnect(request, 8080); } return new Response('WebSocket endpoint', { status: 200 }); } }; ## Related resources - [Preview URLs concept](/sandbox/sdk/concepts/preview-urls/) - How preview URLs work - [Expose Services guide](/sandbox/sdk/guides/expose-services/) - Full workflow for starting services, exposing ports, and routing requests - [WebSocket Connections guide](/sandbox/sdk/guides/websocket-connections/) - WebSocket routing via preview URLs - [Commands API](/sandbox/sdk/api/commands/) - Start background processes - [Tunnels API](/sandbox/sdk/api/tunnels/) - Zero-config `*.trycloudflare.com` URLs for quick development Sessions import { TypeScriptExample, Render } from "~/components"; Create shell sessions within a sandbox. Each session maintains its own shell state, environment variables, and working directory, while sharing the sandbox filesystem and process space. For more information, refer to Session management. :::note By default, for backwards compatibility, every sandbox has a default session that maintains shell state. It is recommended to set enableDefaultSession to false on getSandbox() so operations without an explicit sessionId run in isolation. Create additional sessions for separate workflows inside the same user workspace, such as development and runtime processes using the createSession() method. Use separate sandboxes for separate users. For sandbox-level operations like creating containers or destroying the entire sandbox, refer to the Lifecycle API. ::: Methods createSession() Create a new shell session. const session = await sandbox.createSession(options?: SessionOptions): Promise Parameters: options (optional): id - Custom session ID (auto-generated if not provided) env - Environment variables for this session: Record cwd - Working directory (default: "/workspace") commandTimeoutMs - Maximum time in milliseconds that any command in this session can run before timing out. Individual commands can override this with the timeout option on exec(). Returns: Promise with all sandbox methods bound to this session ```ts // Separate workflow environments const prodSession = await sandbox.createSession({ id: 'prod', env: { NODE_ENV: 'production', API_URL: 'https://api.example.com' }, cwd: '/workspace/prod' }); const testSession = await sandbox.createSession({ id: 'test', env: { NODE_ENV: 'test', API_URL: 'http://localhost:3000', DEBUG_MODE: undefined // Skipped, not set in this session }, cwd: '/workspace/test' }); // Run in parallel const [prodResult, testResult] = await Promise.all([ prodSession.exec('npm run build'), testSession.exec('npm run build') ]); // Session with a default command timeout const session = await sandbox.createSession({ commandTimeoutMs: 5000 // 5s timeout for all commands }); await session.exec('sleep 10'); // Times out after 5s // Per-command timeout overrides session-level timeout await session.exec('sleep 10', { timeout: 3000 }); // Times out after 3s ### `getSession()` Retrieve an existing session by ID. ```ts const session = await sandbox.getSession(sessionId: string): Promise Parameters: sessionId - ID of an existing session Returns: Promise bound to the specified session ```ts // First request - create a task-specific session const session = await sandbox.createSession({ id: 'build' }); await session.exec('git clone https://github.com/user/repo.git'); await session.exec('cd repo && npm install'); // Second request - resume session (environment and cwd preserved) const session = await sandbox.getSession('build'); const result = await session.exec('cd repo && npm run build'); --- ### `deleteSession()` Delete a session and clean up its resources. ```ts const result = await sandbox.deleteSession(sessionId: string): Promise Parameters: sessionId - ID of the session to delete (cannot be "default") Returns: Promise containing: success - Whether deletion succeeded sessionId - ID of the deleted session timestamp - Deletion timestamp ```ts // Create a temporary session for a specific task const tempSession = await sandbox.createSession({ id: 'temp-task' }); try { await tempSession.exec('npm run heavy-task'); } finally { // Clean up the session when done await sandbox.deleteSession('temp-task'); } :::caution Deleting a session immediately terminates all running commands. The default session cannot be deleted. ::: --- ### `setEnvVars()` Set environment variables in the sandbox. ```ts await sandbox.setEnvVars(envVars: Record): Promise Parameters: envVars - Key-value pairs of environment variables to set or unset string values: Set the environment variable undefined or null values: Unset the environment variable :::caution Call setEnvVars() before any other sandbox operations to ensure environment variables are available from the start. ::: ```ts const sandbox = getSandbox(env.Sandbox, 'user-123'); // Set environment variables first await sandbox.setEnvVars({ API_KEY: env.OPENAI_API_KEY, DATABASE_URL: env.DATABASE_URL, NODE_ENV: 'production', OLD_TOKEN: undefined // Unsets OLD_TOKEN if previously set }); // Now commands can access these variables await sandbox.exec('python script.py'); --- ## ExecutionSession methods The `ExecutionSession` object has all sandbox methods bound to the specific session: | Category | Methods | | -------- | ------- | | **Commands** | [`exec()`](/sandbox/sdk/api/commands/#exec), [`execStream()`](/sandbox/sdk/api/commands/#execstream) | | **Processes** | [`startProcess()`](/sandbox/sdk/api/commands/#startprocess), [`listProcesses()`](/sandbox/sdk/api/commands/#listprocesses), [`killProcess()`](/sandbox/sdk/api/commands/#killprocess), [`killAllProcesses()`](/sandbox/sdk/api/commands/#killallprocesses), [`getProcessLogs()`](/sandbox/sdk/api/commands/#getprocesslogs), [`streamProcessLogs()`](/sandbox/sdk/api/commands/#streamprocesslogs) | | **Files** | [`writeFile()`](/sandbox/sdk/api/files/#writefile), [`readFile()`](/sandbox/sdk/api/files/#readfile), [`mkdir()`](/sandbox/sdk/api/files/#mkdir), [`deleteFile()`](/sandbox/sdk/api/files/#deletefile), [`renameFile()`](/sandbox/sdk/api/files/#renamefile), [`moveFile()`](/sandbox/sdk/api/files/#movefile), [`gitCheckout()`](/sandbox/sdk/api/files/#gitcheckout) | | **Environment** | [`setEnvVars()`](/sandbox/sdk/api/sessions/#setenvvars) | | **Terminal** | [`terminal()`](/sandbox/sdk/api/terminal/#terminal) | | **Code Interpreter** | [`createCodeContext()`](/sandbox/sdk/api/interpreter/#createcodecontext), [`runCode()`](/sandbox/sdk/api/interpreter/#runcode), [`listCodeContexts()`](/sandbox/sdk/api/interpreter/#listcodecontexts), [`deleteCodeContext()`](/sandbox/sdk/api/interpreter/#deletecodecontext) | ## Related resources - [Session management concept](/sandbox/sdk/concepts/sessions/) - How sessions work - [Commands API](/sandbox/sdk/api/commands/) - Execute commands Storage import { TypeScriptExample, Render } from "~/components"; Mount S3-compatible storage buckets (R2, S3, GCS) into the sandbox filesystem for persistent data access. mountBucket() supports R2 binding mounts, local R2 binding sync during development, and remote S3-compatible endpoint mounts. Methods mountBucket() Mount an S3-compatible bucket to a local path in the sandbox. await sandbox.mountBucket( bucket: string, mountPath: string, options?: MountBucketOptions ): Promise Parameters: bucket - Bucket identifier When options.endpoint is omitted, pass the Worker R2 binding name (for example, "MY_BUCKET") When options.endpoint is provided, pass the remote bucket name (for example, "my-r2-bucket") mountPath - Local filesystem path to mount at (e.g., "/data") options (optional) - Mount configuration (see MountBucketOptions) ``` // Mount an R2 bucket by Worker binding name await sandbox.mountBucket('MY_BUCKET', '/data'); // Read/write files directly const data = await sandbox.readFile('/data/config.json'); await sandbox.writeFile('/data/results.json', JSON.stringify(data)); // Mount a remote S3-compatible bucket, including explicit R2 endpoints await sandbox.mountBucket('my-bucket', '/storage', { endpoint: 'https://s3.amazonaws.com', credentials: { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY } }); // Mount an R2 bucket during local development with wrangler dev await sandbox.mountBucket('MY_BUCKET', '/local-data', { localBucket: true }); // Mount a prefix from an R2 binding await sandbox.mountBucket('MY_BUCKET', '/user-data', { prefix: '/users/user-123', readOnly: true }); **Throws**: - `InvalidMountPointError` - Invalid mount path or conflicts with existing mounts - `BucketAccessError` - Bucket does not exist or insufficient permissions :::note[Authentication] Authentication depends on the mount mode: 1. Omit `endpoint` to mount an R2 bucket by Worker binding name in production 2. Set `localBucket: true` to use the same R2 binding during local development 3. Set `endpoint` to mount a remote S3-compatible bucket, then provide explicit `credentials` or rely on environment variables (`R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) Endpoint-based mounts remain supported for explicit R2 endpoint configuration and for other S3-compatible providers. See the [Mount Buckets guide](/sandbox/sdk/guides/mount-buckets/) for detailed authentication options. ::: ### `unmountBucket()` Unmount a previously mounted bucket. ```ts await sandbox.unmountBucket(mountPath: string): Promise Parameters: mountPath - Path where the bucket is mounted (e.g., "/data") ``` // Mount, process, unmount await sandbox.mountBucket('MY_BUCKET', '/data'); await sandbox.exec('python process.py'); // Unmount await sandbox.unmountBucket('/data'); :::note[Automatic cleanup] Mounted buckets are automatically unmounted when the container is destroyed. ::: ## Types ### `MountBucketOptions` ```ts interface RemoteMountBucketOptions { endpoint: string; provider?: BucketProvider; credentials?: BucketCredentials; credentialProxy?: boolean; readOnly?: boolean; s3fsOptions?: string[]; prefix?: string; } interface LocalMountBucketOptions { localBucket: true; prefix?: string; readOnly?: boolean; } interface R2BindingMountBucketOptions { endpoint?: never; prefix?: string; readOnly?: boolean; s3fsOptions?: string[]; } type MountBucketOptions = | RemoteMountBucketOptions | LocalMountBucketOptions | R2BindingMountBucketOptions; mountBucket() supports these three modes: R2 binding mount - Omit endpoint to mount by Worker binding name in production Uses credential-less egress interception for R2 Supports prefix, readOnly, and s3fsOptions Local R2 binding mount - Set localBucket: true during wrangler dev Uses the Worker R2 binding directly through local synchronization Supports prefix and readOnly Remote endpoint mount - Set endpoint to mount any S3-compatible provider Supports explicit credentials or environment variable auto-detection Set credentialProxy: true to keep credentials out of the container (egress interception) Supports provider, prefix, readOnly, and s3fsOptions Field details: endpoint (remote endpoint mode only) - S3-compatible endpoint URL R2: 'https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com' S3: 'https://s3.amazonaws.com' GCS: 'https://storage.googleapis.com' localBucket (local development mode only) - Mount an R2 bucket using the Worker's R2 binding during local development with wrangler dev When true, the SDK syncs the R2 binding directly instead of using an S3 endpoint provider (remote endpoint mode only) - Storage provider hint Enables provider-specific optimizations Values: 'r2', 's3', 'gcs' credentials (remote endpoint mode only) - API credentials Contains accessKeyId and secretAccessKey If not provided, uses environment variables credentialProxy (remote endpoint mode only) - Route S3 requests through the Durable Object for signing When true, credentials are never written to the container's disk. The Durable Object intercepts and re-signs all outbound S3 requests at the network layer before forwarding them upstream. Supports AWS SigV4 signing for S3-compatible endpoints (including R2) and HMAC signing for Google Cloud Storage Requires ContainerProxy to be exported from your Worker entrypoint Default: false (backwards compatibility — recommended to set to true; will become the default in a future version) readOnly (optional) - Mount in read-only mode Default: false prefix (optional) - Subdirectory within the bucket to mount When specified, only contents under this prefix are visible at the mount point Must start with / (for example, /data/uploads or /data/uploads/) Default: Mount entire bucket s3fsOptions (R2 binding and remote endpoint modes only) - Advanced s3fs mount flags Type: string[] Example: ['use_cache=/tmp/cache', 'stat_cache_expire=1'] BucketProvider Storage provider hint for automatic s3fs flag optimization. type BucketProvider = "r2" | "s3" | "gcs"; 'r2' - Cloudflare R2 (recommended, applies nomixupload flag) 's3' - Amazon S3 'gcs' - Google Cloud Storage Related resources Mount Buckets guide - Complete bucket mounting walkthrough Files API - Read and write files Terminal import { TypeScriptExample, Render } from "~/components"; Connect browser-based terminal UIs to sandbox shells via WebSocket. The server-side terminal() method proxies WebSocket connections to the container, and the client-side SandboxAddon integrates with xterm.js for terminal rendering. Server-side methods terminal() Proxy a WebSocket upgrade request to create a terminal connection. const response = await sandbox.terminal(request: Request, options?: PtyOptions): Promise Parameters: request - WebSocket upgrade request from the browser (must include Upgrade: websocket header) options (optional): cols - Terminal width in columns (default: 80) rows - Terminal height in rows (default: 24) Returns: Promise — WebSocket upgrade response ```ts // In your Worker's fetch handler return await sandbox.terminal(request, { cols: 120, rows: 30 }); ``` Works with both default and explicitly created sessions: ```ts // Default session return await sandbox.terminal(request); // Specific session const session = await sandbox.getSession('dev'); return await session.terminal(request); ## Client-side addon The `@cloudflare/sandbox/xterm` module provides `SandboxAddon` for xterm.js, which handles the WebSocket connection, reconnection, and terminal resize forwarding. ### `SandboxAddon` ```ts import { SandboxAddon } from '@cloudflare/sandbox/xterm'; const addon = new SandboxAddon(options: SandboxAddonOptions); Options: getWebSocketUrl(params) - Build the WebSocket URL for each connection attempt. Receives: sandboxId - Target sandbox ID sessionId (optional) - Target session ID origin - WebSocket origin derived from window.location (for example, wss://example.com) reconnect - Enable automatic reconnection with exponential backoff (default: true) onStateChange(state, error?) - Callback for connection state changes ```ts import { Terminal } from '@xterm/xterm'; import { SandboxAddon } from '@cloudflare/sandbox/xterm'; const terminal = new Terminal({ cursorBlink: true }); terminal.open(document.getElementById('terminal')); const addon = new SandboxAddon({ getWebSocketUrl: ({ sandboxId, sessionId, origin }) => { const params = new URLSearchParams({ id: sandboxId }); if (sessionId) params.set('session', sessionId); return ${origin}/ws/terminal?${params}; }, onStateChange: (state, error) => { console.log( Terminal ${state}, error); } }); terminal.loadAddon(addon); addon.connect({ sandboxId: 'my-sandbox' }); ### `connect()` Establish a connection to a sandbox terminal. ```ts addon.connect(target: ConnectionTarget): void Parameters: target: sandboxId - Sandbox to connect to sessionId (optional) - Session within the sandbox Calling connect() with a new target disconnects from the current target and connects to the new one. Calling it with the same target while already connected is a no-op. disconnect() Close the connection and stop any reconnection attempts. addon.disconnect(): void Properties Property Type Description state 'disconnected' | 'connecting' | 'connected' Current connection state sandboxId string | undefined Current sandbox ID sessionId string | undefined Current session ID WebSocket protocol The SandboxAddon handles the WebSocket protocol automatically. These details are for building custom terminal clients without the addon. For a complete example, refer to Connect without xterm.js. Connection lifecycle Client opens a WebSocket to your Worker endpoint. Set binaryType to arraybuffer. The server replays any buffered output from a previous connection as binary frames. This may arrive before the ready message. The server sends a ready status message — the terminal is now accepting input. Binary frames flow in both directions: UTF-8 encoded keystrokes from the client, terminal output (including ANSI escape sequences) from the server. If the client disconnects, the PTY stays alive. Reconnecting to the same session replays buffered output so the terminal appears unchanged. Control messages (client to server) Send JSON text frames to control the terminal. Resize — update terminal dimensions (both cols and rows must be positive): { "type": "resize", "cols": 120, "rows": 30 } Status messages (server to client) The server sends JSON text frames for lifecycle events. Ready — the PTY is initialized. Buffered output (if any) has already been sent: { "type": "ready" } Exit — the shell process has terminated: { "type": "exit", "code": 0, "signal": "SIGTERM" } Error — an error occurred (for example, invalid control message or session not found): { "type": "error", "message": "Session not found" } Types interface PtyOptions { cols?: number; rows?: number; } type ConnectionState = "disconnected" | "connecting" | "connected"; interface ConnectionTarget { sandboxId: string; sessionId?: string; } interface SandboxAddonOptions { getWebSocketUrl: (params: { sandboxId: string; sessionId?: string; origin: string; }) => string; reconnect?: boolean; onStateChange?: (state: ConnectionState, error?: Error) => void; } Related resources Terminal connections — How terminal connections work Browser terminals — Step-by-step setup guide Sessions API — Session management Commands API — Non-interactive command execution Tunnels import { TypeScriptExample, Render } from "~/components"; The sandbox.tunnels namespace exposes a service running inside a sandbox on the public internet through a Cloudflare Tunnel. The SDK runs cloudflared inside the container and opens a persistent QUIC connection to Cloudflare's edge. Two flavors are available: Quick tunnels ( sandbox.tunnels.get(port)) — zero-config. Cloudflare assigns a random *.trycloudflare.com hostname for each new cloudflared process. No Cloudflare account, API token, DNS record, or custom domain required. URLs change on every container restart. Named tunnels ( sandbox.tunnels.get(port, { name })) — bind a stable hostname . on a zone you control. The hostname survives container restarts and is shared across sandboxes that request the same name. Requires a Cloudflare API token, an account, and a zone. :::note[When to use quick vs. named tunnels] Use quick tunnels for local development, demos, and short-lived .workers.dev deployments where you do not need a stable URL. Use named tunnels for everything else — they are the recommended option for production traffic, webhook receivers, OAuth callbacks, and any URL that needs to be bookmarked. exposePort() remains an alternative when you want the Worker itself (rather than Cloudflare's edge) to front the request. ::: Requirements Both tunnel flavors require: RPC transport. Calling sandbox.tunnels on HTTP/Websocket transports throws "RPC transport required". See Transport configuration. Named tunnels additionally require a Cloudflare API token, account, and zone — refer to Named tunnels: prerequisites. Methods tunnels.get() Return a tunnel record for port. The SDK spawns a fresh cloudflared process inside the container if not already running. The method is idempotent: repeated calls with the same (port, options) return the same record. const tunnel = await sandbox.tunnels.get( port: number, options?: { name?: string } ): Promise Parameters: port — Port number inside the sandbox to expose (1024-65535, excluding reserved ports). The service to tunnel to must already be listening on 0.0.0.0: inside the container. options.name (optional) — Single DNS label (lowercase letters, digits, internal hyphens; 1–63 chars; no dots). When set, provisions a named tunnel bound to .. When omitted, provisions a quick tunnel. Returns: Promise — the tunnel record. See TunnelInfo. Calling get(port) with different options on a port that already has a tunnel throws. Call destroy(port) first. ```ts import { getSandbox } from "@cloudflare/sandbox"; export { Sandbox } from "@cloudflare/sandbox"; export default { async fetch(request: Request, env: Env): Promise { const sandbox = getSandbox(env.Sandbox, "my-sandbox"); await sandbox.startProcess("python -m http.server 8080"); const tunnel = await sandbox.tunnels.get(8080); console.log(tunnel.url); // → https://random-words-here.trycloudflare.com // Repeated calls for the same port return the same record. const same = await sandbox.tunnels.get(8080); console.log(same.url === tunnel.url); // true return Response.json({ url: tunnel.url }); }, }; ### `tunnels.list()` Return every tunnel currently tracked for this sandbox. ```ts const tunnels = await sandbox.tunnels.list(): Promise Returns: Promise — an array of TunnelInfo records. Empty when no tunnels are active. ```ts const tunnels = await sandbox.tunnels.list(); for (const tunnel of tunnels) { console.log( port ${tunnel.port} → ${tunnel.url}); } ### `tunnels.destroy()` Tear down a tunnel. Accepts either the port number or the `TunnelInfo` record returned by `get()`. Idempotent — destroying an unknown port resolves successfully. ```ts await sandbox.tunnels.destroy(portOrInfo: number | TunnelInfo): Promise Parameters: portOrInfo — Either the port number or the TunnelInfo record returned by get(). ```ts const tunnel = await sandbox.tunnels.get(8080); // Tear down by port number... await sandbox.tunnels.destroy(8080); // ...or by the record. await sandbox.tunnels.destroy(tunnel); ## Types ### `TunnelInfo` Quick tunnels omit `name`; named tunnels carry the label passed via `options.name`. | Field | Type | Description | | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | | `id` | `string` | Tunnel identifier. `quick-` for quick tunnels, the Cloudflare Tunnel UUID for named tunnels. | | `port` | `number` | Port number inside the sandbox that the tunnel proxies to. | | `url` | `string` | Public URL — `https://.trycloudflare.com` (quick) or `https://.` (named). | | `hostname` | `string` | Hostname component of `url`. | | `createdAt` | `string` | ISO-8601 timestamp of when the tunnel was created. | | `name` | `string` | **Named tunnels only.** The label passed via `options.name`. Absent on quick tunnels. | ```ts type TunnelInfo = QuickTunnelInfo | NamedTunnelInfo; interface QuickTunnelInfo { id: string; port: number; url: string; hostname: string; createdAt: string; name?: never; } interface NamedTunnelInfo { id: string; port: number; url: string; hostname: string; createdAt: string; name: string; } ``` ## Named tunnels Named tunnels bind a user-controlled hostname — `.` — backed by a managed [Cloudflare Tunnel](/cloudflare-one/networks/connectors/cloudflare-tunnel/) and a proxied `CNAME` record on your zone. Unlike quick tunnels, the URL is **stable across container restarts** and **shared across sandboxes** that call `get(port, { name })` with the same `name`. ### How they differ from quick tunnels | Aspect | Quick tunnel | Named tunnel | | ----------------------- | --------------------------------------------- | --------------------------------------------------------------------- | | Hostname | Random `*.trycloudflare.com`, assigned by Cloudflare | `.`, chosen by you | | Stability | Changes on every container restart | Stable; persists across restarts and sandbox lifecycles | | Cloudflare account | Not required | Required (API token + zone) | | Cloudflare-side resources | None | Managed [Cloudflare Tunnel](/cloudflare-one/networks/connectors/cloudflare-tunnel/) + proxied DNS `CNAME` | | Uptime guarantee | None (debug aid) | Backed by your zone's standard Cloudflare SLA | | TLS certificate | Cloudflare-owned wildcard | Universal SSL on `.` (single DNS label only) | | Server-Sent Events | Not supported (edge buffers `text/event-stream`) | Supported | ### Prerequisites To provision a named tunnel, you need: 1. A **Cloudflare account** with a **zone** (a domain you control on Cloudflare DNS). 2. A **Cloudflare API token** with the correct scopes. 3. The **account ID** and **zone ID** — the SDK can infer both from the token when the token is scoped to exactly one of each. #### Create the API token Create a token from **My Profile** > **API Tokens** > **Create Token** > **Custom token** with the following permissions: | Scope | Used for | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | **Account** · **Cloudflare Tunnel** · **Edit** | Create, look up, and delete tunnels. | | **Zone** · **DNS** · **Edit** | Upsert and delete the proxied `CNAME` for `.`. | | **Zone** · **Zone** · **Read** | Look up the zone's name to derive `.`. | | **Account** · **Account Settings** · **Read** *(optional)* | Lets the SDK infer the account ID from the token when not set explicitly. | Under **Account Resources**, scope the token to the account that will own the tunnel. Under **Zone Resources**, scope it to the specific zone you want to bind to. Both [User API Tokens](/fundamentals/api/get-started/create-token/) and [Account API Tokens](/fundamentals/api/get-started/account-owned-tokens/) (secret prefixed `cfat_`) are supported — the SDK detects the token kind and uses the appropriate introspection endpoint. #### Create the token with the REST API You can create the token without using the dashboard. The permission group IDs are stable; the snippet below uses placeholders — fetch the current IDs from [`GET /user/tokens/permission_groups`](/api/operations/permission-groups-list-permission-groups/). ```bash curl -X POST "https://api.cloudflare.com/client/v4/user/tokens" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "sandbox-named-tunnels", "policies": [ { "effect": "allow", "resources": { "com.cloudflare.api.account.": "*" }, "permission_groups": [{ "id": "" }] }, { "effect": "allow", "resources": { "com.cloudflare.api.account.zone.": "*" }, "permission_groups": [ { "id": "" }, { "id": "" } ] } ] }' ``` #### Bind the token and IDs to the Worker The SDK reads `CLOUDFLARE_API_TOKEN` from the Worker environment and attempts to derive the account ID and zone ID from the token automatically. If the token is associated with multiple accounts or zones the SDK cannot pick one unambiguously, and you must set `CLOUDFLARE_ACCOUNT_ID` and/or `CLOUDFLARE_ZONE_ID` explicitly. | Variable | Required? | Notes | | ------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------- | | `CLOUDFLARE_API_TOKEN` | Yes | Store as a secret with `wrangler secret put`. | | `CLOUDFLARE_ACCOUNT_ID` | Only if the token sees multiple accounts | Inferred from the token otherwise. | | `CLOUDFLARE_ZONE_ID` | Only if the token sees multiple zones | Inferred from the token otherwise. | ```bash npx wrangler secret put CLOUDFLARE_API_TOKEN ``` For local development, place the variables in `.dev.vars` (gitignored). For production, set the non-secret IDs (when needed) under `vars` in your Wrangler config: ```jsonc { "vars": { "CLOUDFLARE_ACCOUNT_ID": "", "CLOUDFLARE_ZONE_ID": "" } } ``` When inference fails, the SDK throws a clear error naming the variable to set. ### Example ```ts import { getSandbox } from "@cloudflare/sandbox"; export { Sandbox } from "@cloudflare/sandbox"; export default { async fetch(request: Request, env: Env): Promise { const sandbox = getSandbox(env.Sandbox, "my-sandbox"); // Reuse an existing app process across container restarts, or start it. let proc = await sandbox.getProcess('app'); if (!proc) { try { proc = await sandbox.startProcess('python -m http.server 8080', { processId: 'app' }); } catch (err) { if ((err as { code?: string })?.code !== 'PROCESS_ALREADY_EXISTS') throw err; proc = await sandbox.getProcess('app'); } } // Provision (or reuse) https://app.example.com pointing at port 8080. const tunnel = await sandbox.tunnels.get(8080, { name: "app" }); console.log(tunnel.url); // → https://app.example.com return Response.json({ url: tunnel.url }); }, }; ``` :::caution[`name` must be a single DNS label] `name` must match `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` and be 1–63 characters — no dots, no uppercase, no leading/trailing hyphens. This restriction exists because Cloudflare Universal SSL only issues certificates for `