# Migrate from Modal Sandboxes to Sandboxer

> 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.

Source: https://sandboxer.com/migrate/modal. Updated 2026-10-27.

**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](/docs/machines)).

## Concept map

Facts about Modal come from its [Sandboxes guide](https://modal.com/docs/guide/sandboxes) 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](/docs/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](/docs/long-running#auto-stop)) |
| `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](/docs/snapshots#named-snapshots-templates)) |
| 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](/docs/environments)) |
| `encrypted_ports` and `sb.tunnels()` | `sb.host(port)`: `https://<slug>-<port>.box.sandboxer.com` ([Hosting](/docs/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](/docs/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](/docs/sdk-python)), 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](https://modal.com/docs/guide/sandboxes) (the first Python example, checked on 2026-10-27):

```python
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:

```python
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:

```text
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:

```python
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)
```

```text
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](https://modal.com/docs/guide/sandbox-files)), or read it from your Volume with `vol.read_file(...)`. Then send it to the new sandbox with `sbx scp`:

```bash
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](/docs/long-running#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](/docs/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](/docs/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](/docs/snapshots#resume)). Modal's memory snapshots are in alpha, end the sandbox, and expire after 7 days ([Modal docs](https://modal.com/docs/guide/sandbox-snapshots)).
- **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](https://modal.com/docs/guide/sandboxes#runtimes)).
- **A desktop.** Every sandbox has a streamed desktop with Chrome ([Desktop](/docs/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](/docs/billing#plans)). 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](https://modal.com/docs/guide/sandbox-networking)). Our docs describe fixed blocks (other sandboxes, internal networks, SMTP port 25), not per-sandbox allowlists ([Security](/docs/security)).
- **Regions.** Sandboxer has two regions, us-east (Ashburn, Virginia) and eu-central (Helsinki, Finland); see [regions](/docs/regions). Modal's plans list region selection ([Modal pricing](https://modal.com/pricing)).
- **Go SDK.** We have Python and TypeScript SDKs. From Go, call the [REST API](/docs/api).

## Pricing

Modal's sandbox rates come from its [pricing page](https://modal.com/pricing), 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](/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](/docs/sdk-python#async)).

## Related

- [Sandboxer vs Modal](/compare/modal)
- [Quickstart](/docs/quickstart)
- [Python SDK](/docs/sdk-python)
- [Snapshots](/docs/snapshots)
