This page replaces or removes the Sandbox SDK 0.12 features that have no class in 1.0. Each section stands alone. Follow only the sections for the features that your application uses.
Your 1.0 class replaces the 0.12 Sandbox class, and has the container getter, the ensureRunning() and startContainer() methods, and the ENV constant from Replace the Sandbox class. A class that you reach through this.ctx.exports needs compatibility date 2025-11-17 or later, or the enable_ctx_exports flag.
Each 0.12 feature has one section:
| 0.12 feature | Section |
|---|---|
Docker in a docker:dind-rootless image |
Run Docker inside the sandbox |
outboundByHost handlers that read bindings |
Reach Workers bindings from the container |
@cloudflare/sandbox/opencode |
Start an OpenCode server |
@cloudflare/sandbox/openai |
Give OpenAI agents a shell and an editor |
@cloudflare/sandbox/bridge |
Replace the bridge |
WarmPool |
Remove WarmPool |
The 0.x Docker setup starts from docker:dind-rootless, switches to USER root, and copies in the musl build of the 0.x server. On a 1.0 container, its Docker daemon exits with failed to set IP forwarding '/proc/sys/net/ipv4/ip_forward' = '1': open /proc/sys/net/ipv4/ip_forward: read-only file system.
-
Replace your
Dockerfile:Dockerfiledockerfile FROM docker:dind COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim ENTRYPOINT ["sh", "-c", "dockerd-entrypoint.sh dockerd --iptables=false --ip6tables=false --ip-forward=false > /var/log/dockerd.log 2>&1 & exec sleep infinity"]The Docker daemon runs as
root, with iptables and IP forwarding turned off. It logs to/var/log, because thedocker:dindentrypoint mounts an empty/tmp.sleep infinitykeeps the container running. Keep theCOPYline only if your class usesFilesorS3Mount.sandbox-shimis statically linked, so it runs on the Alpine-baseddocker:dindimage. -
Keep
enableInternet: trueinstart(), so that Docker can pull images. 0.12 allowed Internet access by default. -
Wait for Docker, and run containers with
--network=host:src/index.ts (0.12)ts const sandbox = getSandbox(env.Sandbox, "docker-sandbox"); const run = await sandbox.exec( "docker run --rm --network=host alpine:3.22 " + "echo Hello from Docker", );src/index.ts (1.0)ts export class MySandbox extends DurableObject<Env> { // ... private async waitForDocker(): Promise<void> { const wait = await this.container.exec([ "timeout", "30", "sh", "-c", "until docker version > /dev/null 2>&1; do sleep 0.2; done", ]); if ((await wait.exitCode) !== 0) { throw new Error("Docker did not start within 30 seconds"); } } async runImage(): Promise<string> { await this.ensureRunning(); // The Docker daemon starts after the container, so wait for it await this.waitForDocker(); const run = await this.container.exec( [ "docker", "run", "--rm", "--network=host", "alpine:3.22", "echo", "Hello from Docker", ], { env: ENV }, ); const result = await run.output(); return new TextDecoder().decode(result.stdout); } }Without
--network=host,docker runfails withfailed to disable IPv6 on container's interface eth0. Adocker buildstep that uses the network, such as a package install, also needsdocker build --network=host. An inner container started this way shares the network access of the sandbox. -
Call
runImage()from your Worker. It returnsHello from Docker. The first call also pullsalpine:3.22, and Docker writes its progress tostderr.
For more information, refer to Docker-in-Docker in the Containers FAQ.
In 0.12, an outboundByHost handler answered requests to a hostname such as my.kv, and read Workers bindings from its env argument. In 1.0, a WorkerEntrypoint answers them, and your Durable Object registers it for that hostname.
-
Replace each handler with a
WorkerEntrypoint, and export it from the main module of your Worker:src/index.ts (0.12)ts MySandbox.outboundByHost = { "my.kv": async (request: Request, env: Env) => { const key = new URL(request.url).pathname.slice(1); const value = await env.KV.get(key); return new Response(value ?? "", { status: value ? 200 : 404, }); }, };src/index.ts (1.0)ts import { WorkerEntrypoint } from "cloudflare:workers"; type KvProps = { containerId: string }; export class KvGateway extends WorkerEntrypoint<Env, KvProps> { async fetch(request: Request): Promise<Response> { const key = new URL(request.url).pathname.slice(1); const value = await this.env.KV.get(key); return new Response(value ?? "", { status: value === null ? 404 : 200, }); } }this.envhas the same bindings as theenvargument. Where a 0.12 handler readctx.containerId, readthis.ctx.props.containerId. -
Register the entrypoint in the
tryblock ofstartContainer():src/index.tsts export class MySandbox extends DurableObject<Env> { // ... private async startContainer(): Promise<void> { const newContainer = !this.container.running; if (newContainer) { this.container.start({ image: this.container.images.sandbox, instance: "standard-1", env: ENV, // 0.12 allowed Internet access by default. enableInternet: true, }); } try { if (newContainer) { // Replaces onStart(). await this.ctx.storage.put("startedAt", Date.now()); } await this.container.interceptOutboundHttp( "my.kv", this.ctx.exports.KvGateway({ props: { containerId: this.ctx.id.toString() }, }), ); await this.container.setInactivityTimeout( INACTIVITY_TIMEOUT_MS, ); } catch (error) { // The next request starts a new container. await this.container.destroy(); throw error; } } }this.ctx.id.toString()is the value that 0.12 passed ascontainerId, so keys and paths built from it stay the same. The intercept also works withenableInternet: false. Register one entrypoint for each hostname.If your class also uses the
Outboundentrypoint from Move outbound rules, add the handler to itsHANDLERSmap instead. A hostname intercept that you register afterinterceptAllOutboundHttp()receives no requests. -
Request the hostname from the container. After you store
hello from KVunder the keygreeting, this command prints the value:node -e 'fetch("http://my.kv/greeting").then(async (r) => console.log(r.status, await r.text()))'200 hello from KV
In 0.12, createOpencode() started opencode serve in the sandbox, and returned an OpenCode SDK client that reached it through the sandbox. In 1.0, your Durable Object starts the server and creates the client, with a fetch function that connects to the server port.
-
Install OpenCode in your
Dockerfile, as in Run OpenCode in a sandbox:Dockerfiledockerfile RUN npm install --global --ignore-scripts opencode-linux-x64-baseline@1.18.32 \ && ln -s /usr/local/lib/node_modules/opencode-linux-x64-baseline/bin/opencode /usr/local/bin/opencodeThe 0.12
opencodeimage included OpenCode. Start the container with thestandard-1instance type or larger, asstartContainer()does. Onlite, the server did not answer requests within 30 seconds. -
Install the OpenCode SDK in your Worker project:
npm i @opencode-ai/sdk@1.18.32yarn add @opencode-ai/sdk@1.18.32pnpm add @opencode-ai/sdk@1.18.32bun add @opencode-ai/sdk@1.18.32 -
Replace
createOpencode()with a method that starts the server once for each container. It uses the scripts and functions from step 1 of Run a server in the background:src/index.ts (0.12)ts const sandbox = getSandbox(env.Sandbox, "my-agent"); const { client } = await createOpencode(sandbox, { directory: "/workspace", config: OPENCODE_CONFIG, }); const session = await client.session.create({ title: "Fix tests" });src/index.ts (1.0)ts import { createOpencodeClient, type OpencodeClient, } from "@opencode-ai/sdk/v2/client"; const OPENCODE_PORT = 4096; const OPENCODE_DIR = `${SERVERS}/opencode`; // Your OpenCode configuration, without apiKey values. const OPENCODE_CONFIG = {}; const OPENCODE = [ "opencode", "serve", "--port", String(OPENCODE_PORT), "--hostname", "0.0.0.0", ];src/index.ts (1.0)ts export class MySandbox extends DurableObject<Env> { // ... private opencodeReady = false; private async openCode(): Promise<OpencodeClient> { await this.ensureRunning(); if (!this.opencodeReady) { await startServer(this.container, OPENCODE_DIR, OPENCODE, { cwd: "/workspace", env: { ...ENV, OPENCODE_CONFIG_CONTENT: JSON.stringify(OPENCODE_CONFIG), ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY, }, }); await waitForServer( this.container, OPENCODE_DIR, OPENCODE_PORT, 30_000, ); this.opencodeReady = true; } const port = this.container.getTcpPort(OPENCODE_PORT); return createOpencodeClient({ baseUrl: "http://opencode", fetch: (request) => port.fetch(request), }); } async createSession(title: string): Promise<string> { const client = await this.openCode(); const session = await client.session.create({ title }); if (!session.data) { throw new Error("OpenCode did not create the session"); } return session.data.id; } }Remove
apiKeyfrom your OpenCode configuration, and store each key as a secret. Like 0.12, the method passes the configuration asOPENCODE_CONFIG_CONTENTand the key in a provider variable such asANTHROPIC_API_KEY. The container can read the key. To keep model credentials in your Worker, refer to Run OpenCode in a sandbox.Set
this.opencodeReady = falseinstartContainer(), insideif (newContainer), because a new container has no server. If your Durable Object restarts while the container runs,startServer()finds the server that already runs. Use the client in methods of your class, such ascreateSession(). -
Serve the OpenCode web UI from the
fetch()handler of your Durable Object:src/index.tsts export class MySandbox extends DurableObject<Env> { // ... async fetch(request: Request): Promise<Response> { const url = new URL(request.url); const accept = request.headers.get("Accept") ?? ""; const page = accept.includes("text/html") || url.pathname === "/"; // The UI calls the server at the origin in ?url=. if ( request.method === "GET" && page && !url.searchParams.has("url") ) { url.searchParams.set("url", url.origin); return Response.redirect(url.toString(), 302); } await this.openCode(); // The container port accepts only http: URLs. url.protocol = "http:"; return this.container .getTcpPort(OPENCODE_PORT) .fetch(new Request(url, request)); } }This replaces
proxyToOpencode(), which made the same redirect. The UI loads its files from/, so serve it at the root of a hostname and forward every path to your Durable Object, for example withenv.Sandbox.getByName("my-agent").fetch(request). The UI can run commands in the sandbox, so authenticate these requests in your Worker. -
Call
createSession("Fix tests")from your Worker. It returns the new session ID, such asses_f1822cedcffe1CpcMlwM9eQSGZ. Open your Worker URL in a browser. It redirects to/?url=and loads OpenCode.
In 0.12, Shell and Editor from @cloudflare/sandbox/openai ran the commands and file edits of an OpenAI agent in a sandbox, while the agent ran in your Worker. In 1.0, you write both classes over container.exec() and Files, and run the agent in your Durable Object, where the container is.
-
Add a
Shellthat runs each command withbashin/workspace:src/index.tsts import { Files } from "@cloudflare/sandbox"; import { Agent, applyDiff, applyPatchTool, run, shellTool, type ApplyPatchOperation, type ApplyPatchResult, type Editor, type Shell, type ShellAction, type ShellResult, } from "@openai/agents"; class SandboxShell implements Shell { constructor(private readonly container: Container) {} async run(action: ShellAction): Promise<ShellResult> { const timeoutMs = action.timeoutMs ?? 60_000; const seconds = String(Math.ceil(timeoutMs / 1000)); const limit = ["timeout", "--kill-after=5", seconds]; const output: ShellResult["output"] = []; const decoder = new TextDecoder(); for (const command of action.commands) { const process = await this.container.exec( [...limit, "bash", "-c", command], { cwd: "/workspace", env: ENV }, ); const result = await process.output(); const timedOut = result.exitCode === 124; output.push({ stdout: decoder.decode(result.stdout), stderr: decoder.decode(result.stderr), outcome: timedOut ? { type: "timeout" } : { type: "exit", exitCode: result.exitCode }, }); // 0.12 also skipped the rest after a timeout. if (timedOut) { break; } } return { output, providerData: { working_directory: "/workspace" }, }; } }timeoutstops the command and every process it started, and the command exits with code124. 0.12 set no time limit when the action had notimeoutMs, and this class sets 60 seconds. -
Add an
Editorthat applies the patches from the agent withFiles:src/index.tsts type Operation<T> = Extract<ApplyPatchOperation, { type: T }>; class SandboxEditor implements Editor { constructor(private readonly files: Files) {} // Reads each path as relative to /workspace, as 0.12 did, and throws // when `..` leaves /workspace. private path(path: string): string { const root = "/workspace"; const inRoot = path === root || path.startsWith(`${root}/`); const relative = inRoot ? path.slice(root.length) : path; const segments: string[] = []; for (const segment of relative.split("/")) { if (segment === "..") { if (segments.pop() === undefined) { throw new Error(`Operation outside workspace: ${path}`); } } else if (segment !== "" && segment !== ".") { segments.push(segment); } } return [root, ...segments].join("/"); } async createFile( operation: Operation<"create_file">, ): Promise<ApplyPatchResult> { const path = this.path(operation.path); const parent = path.slice(0, path.lastIndexOf("/")); await this.files.mkdir(parent, { recursive: true }); const content = applyDiff("", operation.diff, "create"); await this.files.writeFile(path, content); return { status: "completed", output: `Created ${operation.path}`, }; } async updateFile( operation: Operation<"update_file">, ): Promise<ApplyPatchResult> { const path = this.path(operation.path); const file = await this.files.readFile(path); const current = await file.text(); const content = applyDiff(current, operation.diff); await this.files.writeFile(path, content); return { status: "completed", output: `Updated ${operation.path}`, }; } async deleteFile( operation: Operation<"delete_file">, ): Promise<ApplyPatchResult> { await this.files.remove(this.path(operation.path)); return { status: "completed", output: `Deleted ${operation.path}`, }; } }path()reads/etc/passwdas/workspace/etc/passwd, and throws for../etc/passwd, as 0.12 did. It checks the path only. A symbolic link in/workspacecan still point outside it. For more information, refer to Accept paths from callers.A failed
Filescall throwsSandboxFileError. Both 0.12 classes kept aresultslist, and the 0.12Editoradded afailedresult to it before it threw the error again. If your code read the list, record results in each method. -
Run the agent in a method of your class:
src/index.ts (0.12)ts const sandbox = getSandbox(env.Sandbox, `session-${sessionId}`); const agent = new Agent({ name: "Sandbox agent", instructions: INSTRUCTIONS, tools: [ shellTool({ shell: new Shell(sandbox) }), applyPatchTool({ editor: new Editor(sandbox, "/workspace"), }), ], }); const result = await run(agent, input);src/index.ts (1.0)ts export class MySandbox extends DurableObject<Env> { // ... private readonly files = new Files(this.container); async runAgent(input: string): Promise<string | undefined> { await this.ensureRunning(); const shell = new SandboxShell(this.container); const editor = new SandboxEditor(this.files); const agent = new Agent({ name: "Sandbox agent", instructions: INSTRUCTIONS, tools: [shellTool({ shell }), applyPatchTool({ editor })], }); const result = await run(agent, input); return result.finalOutput; } }Your Worker calls
runAgent()on the stub thatgetByName()returns. The Agents SDK readsOPENAI_API_KEYfrom the environment, as it did in your Worker.If your class already has the
filesfield from Change file calls, keep one of the two. -
Check both classes without a model by calling them directly.
shell.run({ commands: ["pwd", "sleep 10"], timeoutMs: 2000 })returns the working directory, then a timeout:{ "output": [ { "stdout": "/workspace\n", "stderr": "", "outcome": { "type": "exit", "exitCode": 0 } }, { "stdout": "", "stderr": "", "outcome": { "type": "timeout" } } ], "providerData": { "working_directory": "/workspace" } }editor.createFile({ type: "create_file", path: "notes/todo.md", diff: "+first\n+second\n" })returns{ "status": "completed", "output": "Created notes/todo.md" }./workspace/notes/todo.mdthen contains the linesfirstandsecond.
The 0.12 bridge, bridge() from @cloudflare/sandbox/bridge, served an HTTP API over Sandbox calls for clients outside Workers, and checked a SANDBOX_API_KEY bearer token. 1.0 has no bridge. Add a Worker route for each call that your client makes, and have the route call a method of your Durable Object. Check the token in your Worker, as the bridge did.
Clients written for the bridge API, such as CloudflareSandboxClient in the OpenAI Agents SDK for Python, work only with a 0.x bridge Worker. Keep that Worker on 0.x until your client calls your own routes.
Start each sandbox when a request needs it, and remove WarmPool. The pool kept 0.x sandboxes started ahead of time, because a 0.x start took seconds. On 1.0, a sandbox on a prepared image runs its first command in under 600 ms at the median.
-
Remove the
WarmPoolexport from the main module of your Worker, and its binding fromdurable_objectsin the Wrangler configuration. -
Delete the class in the same way your Worker added it. If your Worker uses
migrations, add a migration:{ "migrations": [ { "new_sqlite_classes": ["Sandbox"], "tag": "v1" }, { "new_sqlite_classes": ["WarmPool"], "tag": "v2" }, { "deleted_classes": ["WarmPool"], "tag": "v3" }, ], }[[migrations]] new_sqlite_classes = [ "Sandbox" ] tag = "v1" [[migrations]] new_sqlite_classes = [ "WarmPool" ] tag = "v2" [[migrations]] deleted_classes = [ "WarmPool" ] tag = "v3"If your Worker uses
exports, mark the class as deleted instead:{ "exports": { "WarmPool": { "type": "durable-object", "state": "deleted", }, }, }[exports.WarmPool] type = "durable-object" state = "deleted" -
Remove the
WARM_POOL_TARGET,WARM_POOL_REFRESH_INTERVAL,WARM_POOL_MAX_INSTANCES, andWARM_POOL_SCALE_BATCH_SIZEvariables.bridge()filled the pool from itsscheduled()handler, so remove the cron trigger too if nothing else uses it.
The sandboxes that the pool started are 0.12 sandboxes, so Plan the move covers them like any other.
If your application still needs those last milliseconds, call start() for the sandbox that a user is about to use, for example when they open a session. Do not keep a pool of started sandboxes. Each one is billed while it waits, and your code must assign it to a session.