Migrate from Cloudflare Sandbox to Sandboxer
Updated October 27, 2026 .mdSandboxer vs Cloudflare Sandbox
Short answer: Replace getSandbox() and the Sandbox Durable Object with sandboxer-sdk calls, keyed by a stored sandbox id. Swap the Dockerfile for our Ubuntu image plus a setup script or named snapshot, and exposePort() for host(port). Stopping keeps the disk, and memory on a same-CPU resume. Billing moves from active CPU plus provisioned memory to a flat per-second price.
Concept map#
Cloudflare facts come from the Cloudflare Sandbox docs, read on 2026-10-27. Cloudflare now documents two versions of @cloudflare/sandbox. In 0.x, the package's Sandbox class is your Durable Object, and you call getSandbox(), exec(), writeFile() and exposePort(). In 1.0, you write the Durable Object yourself and call this.ctx.container (Changes in Sandbox SDK 1.0). This page maps the 0.x calls. The same Sandboxer code replaces either version.
| Cloudflare Sandbox | Sandboxer |
|---|---|
| A sandbox is a Durable Object, addressed by a name you choose, that starts a container (lifetime) | A sandbox is a Cloud Hypervisor microVM running Ubuntu 24.04, with an id (sb_…), an optional name and labels (Machines) |
getSandbox(env.Sandbox, "user-123") creates on first use, then reaches the same one |
sandboxer.create() returns an id. Store it, or tag the sandbox with labels and list by them |
Container image: a Dockerfile FROM docker.io/cloudflare/sandbox:<version>, built by wrangler deploy |
One base image with Node.js 22, Python 3 and Docker, plus a setup script, a named snapshot or an environment |
instance_type: lite (1/16 vCPU) up to standard-4 (4 vCPU, 12 GiB) |
type: small 2 vCPU / 4 GB up to xlarge 16 vCPU / 32 GB |
0.x: sleep after 10 minutes idle (sleepAfter); files and processes are lost. 1.0: your app can save a container snapshot that carries files (not processes) to the next start (lifetime) |
Idle auto-pause (idleStopMinutes): a stop with memory, woken by the next request (Long-running) |
keepAlive: true |
sbx idle <id> 0, plus ttlSeconds (or no auto-stop on paid plans) |
createBackup() / restoreBackup() of a directory to R2, default TTL 3 days |
Named snapshot of the whole sandbox, kept until you delete it (Snapshots) |
exposePort() with proxyToSandbox(), on your own wildcard domain |
host(port) on https://<subdomain>-<port>.box.sandboxer.com, private with a token or public (Hosting) |
setEnvVars(), Worker secrets |
env on create, or account-wide environments |
Worker binding env.Sandbox |
One API key, sbx_live_… (API keys) |
wrangler dev runs the container in local Docker |
No local mode. Every sandbox runs in our cloud |
The 0.x lifecycle facts are from Sandbox lifecycle. The image tags are from the Dockerfile reference. Backups are from the Backups API.
API and SDK mapping#
Their calls are from the Sandbox SDK 0.x API reference. Ours are from sandboxer-sdk 0.1.1, which we ran against prod.
Cloudflare (@cloudflare/sandbox 0.x) |
Sandboxer SDK | Sandboxer CLI |
|---|---|---|
getSandbox(env.Sandbox, id) (new) |
sandboxer.create({ name, type, ttlSeconds }) |
sbx new --name … --type … |
getSandbox(env.Sandbox, id) (existing) |
sandboxer.get(id) |
sbx info <id> |
sandbox.exec("npm test") |
sandbox.exec("npm test") |
sbx exec <id> "npm test" |
sandbox.startProcess("node server.js") |
sandbox.start("node server.js") |
sbx exec <id> --detach "…" |
sandbox.writeFile(path, content) |
sandbox.files.write(path, content) |
sbx scp ./file <id>:/home/user/ |
sandbox.readFile(path) |
sandbox.files.read(path) |
sbx scp <id>:/home/user/file . |
sandbox.exposePort(8000, { hostname }) |
sandbox.host(8000) |
sbx host <id> 8000 |
sandbox.unexposePort(8000) |
DELETE /sandboxes/{id}/host?port=8000 |
sbx host hide <id> 8000 |
sandbox.setEnvVars({ … }) |
create({ env: { … } }) |
sbx new -e KEY=value |
sandbox.setKeepAlive(true) |
PATCH /sandboxes/{id} with idleStopMinutes: 0 |
sbx idle <id> 0 |
sandbox.createBackup() |
sandbox.snapshot("name") |
sbx snapshot <id> <name> |
sandbox.restoreBackup() |
sandboxer.create({ from: "name" }) |
sbx new --from <name> |
sandbox.destroy() |
sandbox.delete() |
sbx delete <id> --yes |
| Sleep (automatic) | sandbox.stop(), sandbox.resume() |
sbx stop <id> --wait, sbx resume <id> |
Two calls behave differently. Our exec waits 30 seconds by default and at most 600, so start servers and long jobs with start. And our create makes a new sandbox each time. To make it safe to retry, pass idempotencyKey: the same key returns the same sandbox for 24 hours (API).
Before and after#
From Cloudflare's docs: Sandbox SDK 0.x Getting started. We cut the routing between /run and /file.
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
export { Sandbox } from "@cloudflare/sandbox";
export default {
async fetch(request: Request, env: { Sandbox: DurableObjectNamespace<Sandbox> }) {
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const result = await sandbox.exec('python3 -c "print(2 + 2)"');
await sandbox.writeFile("/workspace/hello.txt", "Hello, Sandbox!");
const file = await sandbox.readFile("/workspace/hello.txt");
return Response.json({ output: result.stdout, content: file.content });
},
};Exposing a port, from Container runtime:
const { hostname } = new URL(request.url);
await sandbox.startProcess("python -m http.server 8000");
const exposed = await sandbox.exposePort(8000, { hostname });In production that URL needs a custom domain with wildcard DNS, not workers.dev (Ports API). You also need the Dockerfile, the containers and durable_objects blocks in wrangler.jsonc, and a migration for the Sandbox class.
The same handler on Sandboxer. It is still a Worker-style fetch handler, but it needs no bindings, Durable Objects or Dockerfile. wrangler.jsonc is just name, main and compatibility_date. Install with npm i sandboxer-sdk and set SANDBOXER_API_KEY as a Worker secret.
import { Sandboxer } from "sandboxer-sdk";
export default {
async fetch(request: Request, env: { SANDBOXER_API_KEY: string }): Promise<Response> {
const sandboxer = new Sandboxer({ apiKey: env.SANDBOXER_API_KEY });
const id = new URL(request.url).searchParams.get("id"); // your stored sandbox id
const sandbox = id
? await sandboxer.get(id)
: await sandboxer.create({ type: "small", name: "aeo-migrate-cf-1", ttlSeconds: 1800 });
const run = await sandbox.exec('python3 -c "print(2 + 2)"');
await sandbox.files.write("workspace/hello.txt", "Hello, Sandbox!");
const file = await sandbox.files.read("workspace/hello.txt");
await sandbox.start("python3 -m http.server 8000", { cwd: "workspace" });
const { url } = await sandbox.host(8000, { public: true });
return Response.json({ id: sandbox.id, output: run.stdout, exitCode: run.exitCode, file, url });
},
};We ran it with wrangler dev (Wrangler 4.153.0, workerd 2026-10-23) on our own machine, against the Sandboxer API. One request created the sandbox and returned in 3.2 seconds (single run, October 27, 2026):
{"id":"sb_hp3kunhv39","output":"4\n","exitCode":0,"file":"Hello, Sandbox!","url":"https://nimble-cozy-glacier-8000.box.sandboxer.com"}curl https://nimble-cozy-glacier-8000.box.sandboxer.com/hello.txt returned Hello, Sandbox!. The URL works on any domain you run the Worker on, because it isn't on your zone. We did not deploy this Worker to Cloudflare's network, so treat deployed Workers as untested. The SDK also runs in Node 20+ and Bun (TypeScript SDK).
Stop and resume with the CLI#
On Cloudflare, an idle sandbox goes to sleep and the next request gets a new instance: in 0.x a fresh container, in 1.0 one your app can start from a container snapshot with the files but not the processes. On Sandboxer, a stop saves memory too, when the resume lands on a machine with the same CPU type; otherwise it boots from disk with files kept and processes lost (Snapshots). We stopped the same sandbox while the Python server ran:
sbx exec sb_hp3kunhv39 "pgrep -f 'http.server 8000'" # 529
sbx stop sb_hp3kunhv39 --wait
sbx resume sb_hp3kunhv39
# {"event":"ready","id":"sb_hp3kunhv39","state":"ready",…}
sbx exec sb_hp3kunhv39 "ps -eo pid,args | grep [h]ttp.server"
# 529 python3 -m http.server 8000The resume took about a second (single run, October 27, 2026). The server kept PID 529, and the hosted URL answered 200 with no restart. sbx stop without --wait returns at once, and a resume sent before the stop finishes gets 409 sandbox_stopping.
Find a sandbox by your own key#
getSandbox() finds a sandbox by a name such as user-123. On Sandboxer, store the id, or put the key in labels and filter on them. Labels are set on POST /sandboxes or PATCH /sandboxes/{id}:
curl -sS -X PATCH https://sandboxer.com/api/v1/sandboxes/sb_hp3kunhv39 \
-H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
-d '{"labels":{"user":"user-123","app":"aeo-migrate-cf"}}'
curl -sS "https://sandboxer.com/api/v1/sandboxes?labels=user=user-123,app=aeo-migrate-cf" \
-H "Authorization: Bearer $SANDBOXER_API_KEY"
# one match: sb_hp3kunhv39, name aeo-migrate-cf-1, labels {"app":"aeo-migrate-cf","user":"user-123"}Unlike a Durable Object name, a label is not unique. Two creates with the same labels make two sandboxes. Use idempotencyKey for a create you might retry.
Move your data and environment#
Image. There is no Dockerfile build. The base image is Ubuntu 24.04 with Node.js, Python 3 and Docker. Put your RUN lines in a setup script, or build once and save a named snapshot. We did both:
sbx snapshot sb_hp3kunhv39 aeo-migrate-cf-tpl
# {"type":"snapshot.named.info","snapshot":{"name":"aeo-migrate-cf-tpl",…}}
sbx new --from aeo-migrate-cf-tpl --name aeo-migrate-cf-2 --type small --ttl 900 \
-e APP_MODE=staging --setup-file ./setup.sh
# {"event":"ready","id":"sb_23ijnphdwl","state":"ready",…} (3 seconds)
sbx exec sb_23ijnphdwl 'cat workspace/hello.txt; echo; python3 proj/main.py; echo APP_MODE=$APP_MODE; cat setup.log'
# Hello, Sandbox!
# hi from my project
# APP_MODE=staging
# setup-donesetup.sh ran a pip install in the background after the sandbox was ready, then wrote setup.log. A sandbox started from a named snapshot boots from its disk, so the Python server was not running in it. For repos and secrets that every sandbox gets, use an environment.
Paths. Cloudflare's default working directory is /workspace. Ours is /home/user, and files.write paths are relative to it. Change absolute /workspace paths, or keep a workspace folder as above.
Files. Containers don't move between providers. Read files out with readFile(), or restore a backup locally. Then tar them and upload with sbx scp ./proj.tgz <id>:/home/user/, which we ran. Use sbx scp -r for a directory. files.write takes files up to 5 MiB, and files.download reads up to 50 MiB.
Backups. R2 backups don't carry over. A named snapshot covers the whole disk, not one directory, and has no TTL. It counts toward your plan's included storage (Billing).
Env vars and secrets. setEnvVars() becomes env on create (-e KEY=value on the CLI). For values every sandbox needs, use sbx env set-var base KEY=value or sbx env set-file base path --from ./file (Environments).
Ports and preview URLs. Drop proxyToSandbox(), the wildcard DNS route and the EXPOSE lines. Call host(port) once the server is up. URLs are private by default: our sbx host sb_23ijnphdwl 8000 URL returned 401 without its _token. Pass public: true or --public for an open URL. The subdomain stays the same across stop and resume. A sandbox started from a snapshot gets its own subdomain, so host its ports again.
What's different#
What you gain:
- Memory on stop. A resume on a machine with the same CPU type brings back running processes with the same PIDs, as above. Cloudflare's 0.x sleep deletes files and processes. Its 1.0 container snapshots save files only, and expire after 30 days (lifetime).
- Work inside the VM keeps it running. On Cloudflare, code running inside the instance doesn't count as activity, so a long build can stop when the inactivity timeout ends. Our idle pause waits while a command runs or the load average is at least 0.5 (Long-running). Auto-stop is a deadline you set, and on paid plans you can turn it off.
- Bigger machines and a full VM. Up to 16 vCPU and 32 GB, against
standard-4at 4 vCPU and 12 GiB. Every sandbox has root, Docker,/dev/kvm, SSH and a desktop (Machines). - Preview URLs without a domain. You don't need a zone, wildcard DNS or TLS setup.
- Any caller. The REST API, the CLI, SSH and the Python SDK work from anywhere, not only from a Worker.
What you lose or must change:
- Workers integration. No bindings: the sandbox can't call your KV, R2 or Durable Objects directly. Cloudflare can intercept outbound HTTP in a Worker and add credentials the sandbox never sees. Our docs describe no outbound interception, and sandboxes reach the internet by default (Security).
- Edge placement. On Cloudflare, the first request decides where a sandbox runs. On Sandboxer, a new sandbox goes to the region nearest the caller's IP country unless you set
region. Sandboxer has two regions, us-east (Ashburn, Virginia) and eu-central (Helsinki, Finland); see regions. - Local development.
wrangler devruns Cloudflare containers in Docker on your machine. Every Sandboxer sandbox is a cloud machine. - R2 mounts and Dynamic Workers. Cloudflare mounts R2 buckets and runs JavaScript-only code in Dynamic Workers. We have neither.
- Billing granularity. Cloudflare bills in 10 ms steps and charges CPU only when it is busy. Small or bursty sandboxes can cost less there. See below.
- One bill. If you already pay for Workers, Sandboxer is a second vendor and a second key.
Pricing#
Cloudflare's rates are from its Containers pricing page (last updated 2026-10-05, checked 2026-10-27). Ours are from /pricing. Cloudflare bills active CPU at $0.000020 per vCPU-second ($0.072 per vCPU-hour). It bills provisioned memory at $0.0000025 per GiB-second ($0.009 per GiB-hour) and disk at $0.00000007 per GB-second, while the instance runs. Sandboxer bills a flat rate per second while a sandbox runs.
Cloudflare has no 4 vCPU / 8 GB instance, and custom types need at least 3 GiB per vCPU (limits). So we compare our default size with standard-4 (4 vCPU, 12 GiB, 20 GB disk):
| One hour | Price |
|---|---|
Sandboxer default (4 vCPU / 8 GB) |
$0.036/hour |
Cloudflare standard-4, CPU busy the whole hour |
$0.401 (4 × $0.072 + 12 × $0.009 + disk) |
Cloudflare standard-4, CPU busy 10% of the hour |
$0.142 (0.4 × $0.072 + 12 × $0.009 + disk) |
Memory is billed on Cloudflare while the instance runs, even when idle. So standard-4 costs at least $0.113 an hour before any CPU. A machine running 8 hours a day for 22 days (176 hours) costs $6.34 on Sandboxer, and $24.96 to $70.58 on Cloudflare, depending on CPU use. That excludes Workers and Durable Objects charges and the $5 a month Workers Paid plan. The plan includes 375 vCPU-minutes, 25 GiB-hours of memory and 200 GB-hours of disk a month. For many short runs on lite or basic instances, Cloudflare's 10 ms billing can come out cheaper. Our trial is 7 days with 25 hours and needs a card.
FAQ#
Does sandboxer-sdk run inside a Cloudflare Worker?#
We ran it in wrangler dev, which uses the same workerd runtime, and it created a sandbox, ran commands, wrote files and hosted a port. We did not test a deployed Worker. Our test wrangler.jsonc had no nodejs_compat flag.
What replaces the Durable Object name in getSandbox()?#
The sandbox id that create returns. Store it where you kept the name, for example in KV or your database. To look sandboxes up by user, set labels and list with ?labels=user=user-123. The filter matches sandboxes that carry all the labels you pass.
Do I still need a Dockerfile?#
No. Start from our base image and add a setup script with --setup-file, or save a configured sandbox as a named snapshot and create from it with --from.
What happens to an idle sandbox?#
It pauses after idleStopMinutes (30 minutes on the $20 plan) with its memory saved. A wake restores that memory on a machine with the same CPU type; otherwise it boots from disk with files kept. The next API call or hosted-URL request wakes it. A sandbox you stop yourself stays stopped until you resume it.
Is there a Python SDK?#
Yes. Install sandboxer-sdk from PyPI. It imports as sandboxer and has the same methods in snake_case (Python SDK).