Skip to content
Migrate · Modal

Migrate from Modal Sandboxes to Sandboxer

Updated October 27, 2026 .mdSandboxer vs Modal

Short answer: Port Modal Sandbox code to Sandboxer's Python SDK. Create, exec and file calls map almost one to one. Images become setup scripts or named snapshots, tunnels become hosted HTTPS ports, and the timeout becomes auto-stop that keeps memory. Copy files with sbx scp. Keep GPU workloads on Modal, because Sandboxer has no GPUs.

This page covers Modal Sandboxes: modal.Sandbox.create, sb.exec, file access, snapshots and tunnels. It doesn't cover Modal Functions or GPU jobs. If your sandboxes need a GPU, stay on Modal: Sandboxer has no GPU sizes (Machines).

Concept map#

Facts about Modal come from its Sandboxes guide and the pages linked below, checked on 2026-10-27.

Modal Sandboxer
Sandbox: a gVisor container by default, or the VM runtime with its own kernel Sandbox: a Cloud Hypervisor microVM with its own kernel, every time (Machines)
App (modal.App.lookup(...)), needed to create a sandbox from outside Modal No app object. An API key, plus an optional organization
Image, or a named Image (modal.Image.from_name) Ubuntu 24.04 base, plus a setup script or a named snapshot (from_=)
cpu and memory requests, with bursting Four fixed sizes: small, default, large, xlarge
timeout: default 5 minutes, at most 24 hours ttl_seconds: default 1 hour, up to 30 days, or none on a paid plan (Long-running)
idle_timeout terminates an idle sandbox Idle auto-pause stops it with memory and wakes it on use (sbx new --idle 60)
sb.terminate() sb.delete(), or sb.stop() to keep it for later
Filesystem snapshot (an Image), default TTL 30 days Named snapshot (template), kept until you delete it (Snapshots)
Memory snapshot (alpha), ends the sandbox, expires after 7 days Every stop saves memory; resume() brings back the same sandbox
Several sandboxes from one filesystem snapshot sb.fork(), or from_= a named snapshot
modal.Secret.from_dict(...), secrets=[...] env={...} per sandbox, or an environment (sbx env) for every sandbox (Environments)
encrypted_ports and sb.tunnels() sb.host(port): https://<slug>-<port>.box.sandboxer.com (Hosting)
Sandbox Connect Token Private hosted URL with a _token (the default)
unencrypted_ports (raw TCP) SSH over a forwarded IPv4 port, or the sandbox's public IPv6 address (SSH)
Python, JavaScript and Go SDKs Python and TypeScript SDKs (sandboxer-sdk), the sbx CLI and the REST API

API and SDK mapping#

Modal's calls are from its Python SDK docs. Ours are from sandboxer-sdk 0.1.1 (Python SDK), which we ran on prod for this page.

Modal (Python) Sandboxer (Python) sbx CLI
modal.Sandbox.create(app=app, timeout=600) client.create(type="small", ttl_seconds=600) (waits until ready) sbx new --type small --ttl 600
Sandbox.create(image=modal.Image.from_name("x")) client.create(from_="x") sbx new --from x
sb.exec("python", "-c", "...", timeout=3) sb.exec(["python3", "-c", "..."], timeout_seconds=3) sbx exec <id> -- python3 -c "..."
p.stdout.read(), p.returncode run.stdout, run.exit_code exit code of sbx exec
for line in p.stdout: for frame in sb.exec_stream(...): sbx exec streams by default
Command left running in the background sb.start("..."), then attach(), status(), kill() sbx exec <id> --detach "..."
sb.filesystem.write_text(text, path) sb.files.write(path, text) sbx scp ./file <id>:/home/user/
sb.filesystem.read_text(path) sb.files.read(path) sbx scp <id>:/home/user/file .
sb.snapshot_filesystem() sb.snapshot("name") sbx snapshot <id> name
sb._experimental_snapshot(), Sandbox._experimental_from_snapshot(s) sb.stop(), sb.resume() sbx stop <id>, sbx resume <id>
sb.tunnels()[8080].url sb.host(8080).url sbx host <id> 8080
secrets=[modal.Secret.from_dict({...})] env={...} sbx new -e KEY=value
readiness_probe=..., sb.wait_until_ready() setup_script=..., wait_for_setup=True sbx new --setup-file ./setup.sh
sb.terminate() sb.delete() sbx delete <id> --yes

Two habits change. Modal's exec takes the command as separate arguments; ours takes a shell string or one list. And Modal ends a sandbox when its timeout runs out, while ours stops it with memory and disk kept, so you can resume it.

Before and after#

From Modal's docs: https://modal.com/docs/guide/sandboxes (the first Python example, checked on 2026-10-27):

sb_app = modal.App.lookup("my-app", create_if_missing=True)
sb = modal.Sandbox.create(app=sb_app)

p = sb.exec("python", "-c", "print('hello')", timeout=3)
print(p.stdout.read())

p = sb.exec("bash", "-c", "for i in {1..10}; do date +%T; sleep 0.5; done", timeout=5)
for line in p.stdout:
    # Avoid double newlines by using end="".
    print(line, end="")

sb.terminate()

The same script on Sandboxer. Install the SDK with pip install sandboxer-sdk and set SANDBOXER_API_KEY. Sandboxer() reads the key from the environment, and there's no app to look up:

from sandboxer import Sandboxer

client = Sandboxer()
sb = client.create(type="small", ttl_seconds=600, name="aeo-migrate-modal-1")

p = sb.exec(["python3", "-c", "print('hello')"], timeout_seconds=3)
print(p.stdout)

stream = sb.exec_stream(["bash", "-c", "for i in {1..10}; do date +%T; sleep 0.5; done"], timeout_seconds=5)
for frame in stream:
    if frame.type == "stdout":
        print(frame.data, end="")

print(sb.id, sb.delete().status)

We ran it on prod on 2026-10-27. The whole script, including creating the VM and deleting it, took 8.1 seconds in that one run:

hello

01:52:03
01:52:03
01:52:04
…
01:52:07
01:52:07
sb_m9bt613yov pending

python is python3 on our Ubuntu image. delete() returns a deletion operation that finishes in the background, so its status is pending at first. A GET on the sandbox returned 404 a few minutes later.

Files, ports, stop and resume#

This script covers what a Modal sandbox usually does next: a secret, a setup step, a file, a web server on a port, and a pause. We ran it on prod:

import time, urllib.request
from sandboxer import Sandboxer

client = Sandboxer()
setup = "#!/bin/bash\nset -e\npip install --user --break-system-packages -q requests\ntouch ~/setup-done\n"
sb = client.create(type="small", ttl_seconds=1800, name="aeo-migrate-modal-2",
                   env={"MY_SECRET": "hello"}, setup_script=setup, wait_for_setup=True)
print("created", sb.id, sb.state)
print("env:", sb.exec("echo $MY_SECRET").stdout, end="")
print("setup:", sb.exec("ls ~/setup-done && python3 -c 'import requests; print(requests.__version__)'").stdout, end="")

sb.files.write("tmp/test.txt", "Hello World!\n")
print("read:", sb.files.read("tmp/test.txt"), end="")

sb.start("python3 -m http.server 8080", cwd="tmp")
time.sleep(1)
h = sb.host(8080, public=True)
print("hosted:", h.url)
print("GET /test.txt:", urllib.request.urlopen(h.url + "/test.txt", timeout=10).read().decode(), end="")
print("pid before:", sb.exec("pgrep -f 'http.server 8080'").stdout, end="")

t = time.time(); sb.stop(); print("stopped", sb.state, f"{time.time()-t:.1f}s")
t = time.time(); sb.resume(); print("resumed", sb.state, f"{time.time()-t:.1f}s")
print("pid after:", sb.exec("pgrep -f 'http.server 8080'").stdout, end="")
print("GET after resume:", urllib.request.urlopen(h.url + "/test.txt", timeout=30).read().decode(), end="")

t = time.time(); snap = sb.snapshot("aeo-migrate-modal-tpl"); print("named snapshot", snap.name, snap.status, f"{time.time()-t:.1f}s")
sb3 = client.create(from_="aeo-migrate-modal-tpl", type="small", ttl_seconds=900, name="aeo-migrate-modal-3")
print("from template", sb3.id, sb3.exec("cat tmp/test.txt; ls ~/setup-done").stdout, end="")
print("ids", sb.id, sb3.id)
created sb_dbu5fzbjvj ready
env: hello
setup: /home/user/setup-done
2.31.0
read: Hello World!
hosted: https://fancy-jaunty-cumin-8080.box.sandboxer.com
GET /test.txt: Hello World!
pid before: 546
stopped archived 4.2s
resumed ready 1.4s
pid after: 546
GET after resume: Hello World!
named snapshot aeo-migrate-modal-tpl ready 2.1s
from template sb_1v624544ja Hello World!
/home/user/setup-done
ids sb_dbu5fzbjvj sb_1v624544ja

What this shows:

  • wait_for_setup=True plays the role of a Modal readiness probe: create returned after the setup script finished.
  • The server started with sb.start was still running with the same PID (546) after stop() and resume(). The stop took 4.2 seconds and the resume 1.4 seconds. On Modal, terminate() or a timeout ends the sandbox.
  • The named snapshot took 2.1 seconds. The sandbox started from it had the file and the setup marker without a setup script of its own.
  • Paths in files.write and files.read are relative to /home/user. Absolute paths under /home/user or /tmp work too.

Move your data and environment#

Files. A Modal sandbox can't move over. Copy out what you need with sb.filesystem.copy_to_local(...) (Modal docs), or read it from your Volume with vol.read_file(...). Then send it to the new sandbox with sbx scp:

tar czf proj.tgz proj
sbx scp ./proj.tgz sb_dbu5fzbjvj:/home/user/                          # exit 0
sbx exec sb_dbu5fzbjvj "tar xzf proj.tgz && python3 proj/main.py"     # hi from my modal project
sbx exec sb_dbu5fzbjvj --cwd /tmp --timeout 60 -- python3 -c "print(42)"   # 42
sbx scp sb_dbu5fzbjvj:/home/user/proj/main.py ./back.py               # and back out

For a directory, use sbx scp -r. The SDK's files.write and files.read take files up to 5 MiB, and files.download up to 50 MiB.

Volumes. Our docs don't describe a volume shared by many sandboxes. A sandbox's disk is kept across stop and resume, so data you put there stays. To give many sandboxes the same data, put it in a named snapshot and start them from_= it, or download it in a setup script.

Images. Turn your Modal Image definition into a shell script. pip_install("pandas") becomes pip install pandas, and apt_install("curl") becomes sudo apt-get install -y curl. Pass the script as setup_script= (up to 64 KB). It runs in the background once the sandbox is ready (Setup scripts). If installs are slow, run the script once, save the result with sb.snapshot("web-stack"), and start later sandboxes with client.create(from_="web-stack"). That's the same split Modal recommends with named Images: build once, then start from the result. Docker is already installed in every sandbox, so you don't need a VM runtime or a dockerd entrypoint.

Snapshots. Modal snapshots don't carry across. Rebuild a template from a setup script, as above. Two things are simpler here. A stop saves memory every time, so you don't need _experimental_enable_snapshot, and nothing expires after 7 days. And named snapshots don't expire: they're kept until you delete them.

Secrets. Replace modal.Secret.from_dict({...}) with env={...} on create for one sandbox. For values every sandbox should get, use an environment: sbx env set-var base KEY=value or POST /secrets (Environments).

Ports. Modal opens ports at create time with encrypted_ports. Here you host a port at any time with sb.host(port) or sbx host <id> <port>. The URL is private with a _token by default; pass public=True to open it. The URL keeps its name across stop and resume. If your server listens on 127.0.0.1 only, use host(port, localhost=True). Each host call sets the mode again: when we later ran sbx host sb_dbu5fzbjvj 8080 without --public, the URL went back to private with a token.

Timeouts. Modal's default is 5 minutes. Ours is 1 hour, and the sandbox stops instead of ending. Set ttl_seconds on create, call sb.extend(ttl_seconds) to push the deadline, or pass ttl_seconds=None on a paid plan to turn auto-stop off. During the trial, the most is 8 hours. Synchronous exec calls time out after at most 600 seconds; use sb.start(...) for longer jobs (Long-running).

What's different#

What you gain:

  • Memory survives a stop. In our run, the web server kept the same PID across stop() and resume(), and its URL answered right after. Memory is restored when the resume lands on a machine with the same CPU type; otherwise the sandbox boots from disk and shows memoryRestored: false (Snapshots). Modal's memory snapshots are in alpha, end the sandbox, and expire after 7 days (Modal docs).
  • Longer lifetimes. A Modal sandbox runs at most 24 hours. A Sandboxer sandbox on a paid plan can run with no time limit, and stop and resume as often as you like.
  • A full machine by default. Every sandbox is a VM with systemd, Docker and /dev/kvm. On Modal, Docker needs the VM runtime and nested virtualization is on the Enterprise plan (Modal docs).
  • A desktop. Every sandbox has a streamed desktop with Chrome (Desktop).

What you lose or must change:

  • GPUs. Modal offers GPU sandboxes. Sandboxer has none, so keep GPU work on Modal.
  • Start volume. Our plans limit starts: 12 a minute, 60 an hour and 200 a day on the $20 plan, up to 2,400 a day on the $2,000 plan (Billing). Creates, resumes and forks each count. Check with sbx limits before a burst.
  • Sizing. Modal bills max(request, actual) for any CPU and memory you ask for. We sell four fixed sizes at a flat price.
  • Network policy. Modal has block_network and outbound CIDR and domain allowlists (Modal docs). Our docs describe fixed blocks (other sandboxes, internal networks, SMTP port 25), not per-sandbox allowlists (Security).
  • Regions. Sandboxer has two regions, us-east (Ashburn, Virginia) and eu-central (Helsinki, Finland); see regions. Modal's plans list region selection (Modal pricing).
  • Go SDK. We have Python and TypeScript SDKs. From Go, call the REST API.

Pricing#

Modal's sandbox rates come from its pricing page, checked on 2026-10-27: $0.00003942 per physical core per second (one core is 2 vCPU) and $0.00000667 per GiB of memory per second. Ours come from /pricing.

Machine Modal Sandboxer
4 vCPU, 8 GB, one hour 2 cores and 8 GiB: about $0.476 per hour, more if it bursts default: $0.036/hour
2 vCPU, 4 GB, one hour 1 core and 4 GiB: about $0.238 per hour small: $0.018/hour
After the work is done The sandbox ends; there's no stopped state to pay for A stopped sandbox isn't billed; it counts toward included storage (50 GB on the $20 plan)

Both bill per second while a sandbox runs. One 4 vCPU, 8 GB sandbox running 8 hours a day for 22 days is 176 hours: about $83.76 on Modal at the request rate, and $6.34 on Sandboxer. Modal's Starter plan is $0 a month plus compute, with $30 of free credit each month. Its GPU prices have no counterpart here. Sandboxer plans start at $20 a month, paid back as machine time, with a 7-day trial of 25 hours.

FAQ#

Can I keep using Modal for GPU jobs and move only sandboxes?#

Yes. That's the split we suggest. Sandboxer has no GPUs, so run GPU Functions and GPU sandboxes on Modal and move CPU sandboxes, like agent workspaces and code execution, to Sandboxer.

Do I need an App object on Sandboxer?#

No. Sandboxer() reads SANDBOXER_API_KEY from the environment, and client.create(...) returns a ready sandbox. To bill a team, pass org= to the client.

What replaces sb.terminate()?#

sb.delete() removes the sandbox and its data. If you might come back to it, call sb.stop() instead: a stopped sandbox costs nothing to run and sb.resume() restores its memory and processes on a machine with the same CPU type (otherwise it boots from disk with files intact).

How do I port a Modal Image?#

Write its steps as a shell script and pass it as setup_script=. Run it once, save the result with sb.snapshot("name"), and start new sandboxes with from_="name" so they skip the installs.

Is there an async client like Sandbox.create.aio?#

Yes. AsyncSandboxer mirrors the sync client with async and await (Python SDK).

All migration guides