migrate-from-modal
Migrate from Modal to Blaxel
This guide covers migrating cloud sandbox code from Modal (modal) to Blaxel (@blaxel/core / blaxel).
Package replacement
| Language | Modal | Blaxel |
|---|---|---|
| TypeScript | modal |
@blaxel/core |
| Python | modal |
blaxel |
# TypeScript
npm uninstall modal
npm install @blaxel/core
# Python
pip uninstall modal
pip install blaxel
Authentication
| Modal | Blaxel |
|---|---|
MODAL_TOKEN_ID + MODAL_TOKEN_SECRET env vars |
BL_WORKSPACE + BL_API_KEY env vars |
Or modal setup CLI command |
Or bl login YOUR-WORKSPACE CLI command |
| Requires two secrets (token pair) | CLI login, .env, env vars, or config file (auto-detected) |
Blaxel authenticates automatically when logged in via CLI (bl login YOUR-WORKSPACE). No need to manage token pairs.
API mapping
Imports
TypeScript:
// BEFORE (Modal)
import { ModalClient, Sandbox } from 'modal'
// AFTER (Blaxel)
import { SandboxInstance } from '@blaxel/core'
Python:
# BEFORE (Modal)
import modal
# AFTER (Blaxel)
from blaxel.core import SandboxInstance
Sandbox creation
Modal requires instantiating a client, creating an app, building an image, then creating a sandbox. Blaxel uses a single static method.
TypeScript:
// BEFORE (Modal)
const client = new ModalClient()
const app = await client.apps.fromName('my-app', { createIfMissing: true })
const image = client.images.fromRegistry('python:3.11-slim')
const sandbox = await client.sandboxes.create(app, image, {
regions: ['us-west-2'],
memoryMiB: 2048,
cpu: 1,
timeoutMs: 1000 * 60 * 15,
})
// AFTER (Blaxel)
const sandbox = await SandboxInstance.createIfNotExists({
name: 'my-sandbox',
image: 'blaxel/base-image:latest',
region: 'us-pdx-1',
memory: 2048,
ports: [{ target: 3000, protocol: 'HTTP' }],
envs: [
{ name: 'NODE_ENV', value: 'production' },
],
})
Python:
# BEFORE (Modal)
app = modal.App("my-app")
image = modal.Image.debian_slim().pip_install("numpy")
sandbox = app.spawn_sandbox(
image=image,
timeout=900, # seconds (max 24h)
cpu=1,
memory=2048, # MiB
)
# AFTER (Blaxel)
sandbox = await SandboxInstance.create_if_not_exists({
"name": "my-sandbox",
"image": "blaxel/base-image:latest",
"region": "us-pdx-1",
"memory": 2048,
"ports": [{"target": 3000, "protocol": "HTTP"}],
"envs": [
{"name": "NODE_ENV", "value": "production"},
],
})
Parameter mapping
| Parameter | Modal | Blaxel |
|---|---|---|
| Image | client.images.fromRegistry(...) (programmatic) |
image: 'blaxel/base-image:latest' (pre-built or custom Dockerfile) |
| Memory | memoryMiB: 2048 |
memory: 2048 (MB) |
| CPU | cpu: 1 |
Not needed (auto-allocated: CPU cores = memory / 2048) |
| Region | regions: ['us-west-2'] |
region: 'us-pdx-1' |
| Timeout | timeoutMs: 900000 (max 24h) |
ttl: '24h' |
| Idle timeout | idle_timeout: 300 (seconds, terminates) |
Auto scale-to-zero (~5s, suspends with <25ms resume). For idle-based deletion, use lifecycle: { expirationPolicies: [{ type: 'ttl-idle', value: '7d', action: 'delete' }] } |
| Name | name: 'my-sandbox' (within app) |
name: 'my-sandbox' (global) |
| Tags | tags: { k: 'v' } |
labels: { k: 'v' } |
| Ports | encrypted_ports: [3000] / unencrypted_ports: [3000] |
ports: [{ target: 3000, protocol: 'HTTP' }] |
| Environment vars | Inline secrets at creation | envs: [{ name: 'KEY', value: 'val' }] |
| Secrets | Modal secrets (vault-based) | envs: [{ name: 'KEY', value: 'val', secret: true }] |
| Network control | block_network: true, cidr_allowlist: [...] |
N/A (sandboxes have full network access) |
| GPU | gpu: 'A10G' (T4, L4, A10G, A100, H100, etc.) |
N/A (Blaxel sandboxes do not support GPU) |
| Entrypoint command | command: ['python3', 'server.py'] at creation |
Not supported at creation — use process.exec() immediately after |
| Proxy / network routing | proxy: modal.proxies.fromName(...) |
network: { vpcName: 'my-vpc', egressGatewayName: 'my-gw' } (VPC + egress gateway) |
| Cloud bucket mounts | cloudBucketMounts: { '/mnt': ... } (S3/GCS) |
N/A (use process.exec() with AWS CLI/gsutil to sync data) |
| HTTP/2 ports | h2Ports: [8080] |
N/A (use ports: [{ target: 8080, protocol: 'HTTP' }] — HTTPS handled via preview URLs) |
| App | Required App object |
Not needed |
Entrypoint command at creation
Modal allows running a command directly at sandbox creation. Blaxel requires creating the sandbox first, then executing:
TypeScript:
// BEFORE (Modal) - command at creation
const sandbox = await client.sandboxes.create(app, image, {
command: ['python3', '-m', 'http.server', '8080'],
})
// AFTER (Blaxel) - create then exec
const sandbox = await SandboxInstance.createIfNotExists({
name: 'my-sandbox',
image: 'blaxel/base-image:latest',
ports: [{ target: 8080, protocol: 'HTTP' }],
})
await sandbox.process.exec({
command: 'python3 -m http.server 8080',
waitForPorts: [8080],
})
Python:
# BEFORE (Modal)
sandbox = app.spawn_sandbox(image=image, command=["python3", "-m", "http.server", "8080"])
# AFTER (Blaxel)
sandbox = await SandboxInstance.create_if_not_exists({
"name": "my-sandbox",
"image": "blaxel/base-image:latest",
"ports": [{"target": 8080, "protocol": "HTTP"}],
})
await sandbox.process.exec({
"command": "python3 -m http.server 8080",
"wait_for_ports": [8080],
})
Custom images / templates
Modal builds images programmatically with chained SDK methods. Blaxel uses standard Dockerfiles deployed via bl deploy.
TypeScript:
// BEFORE (Modal) - programmatic image building
const image = client.images.fromRegistry('ubuntu:22.04')
.dockerfileCommands([
'RUN apt-get update && apt-get install -y curl git',
'RUN curl -fsSL https://bun.sh/install | bash',
'RUN mkdir -p /workspace',
])
const sandbox = await client.sandboxes.create(app, image, { regions: ['us-west-2'] })
// AFTER (Blaxel) - Dockerfile-based templates deployed with `bl deploy`
// 1. Create template: bl new sandbox my-template
// 2. Edit the Dockerfile
// 3. Deploy: bl deploy
// 4. Use the deployed image:
const sandbox = await SandboxInstance.createIfNotExists({
name: 'my-sandbox',
image: 'my-deployed-template-image',
region: 'us-pdx-1',
memory: 2048,
})
Python:
# BEFORE (Modal) - programmatic image building
image = (
modal.Image.debian_slim()
.apt_install("curl", "git")
.run_commands("curl -fsSL https://bun.sh/install | bash")
.pip_install("numpy", "pandas")
)
sandbox = app.spawn_sandbox(image=image)
# AFTER (Blaxel) - deploy a Dockerfile via `bl deploy`, then reference it:
sandbox = await SandboxInstance.create_if_not_exists({
"name": "my-sandbox",
"image": "my-deployed-template-image",
"region": "us-pdx-1",
"memory": 2048,
})
Blaxel Dockerfile equivalent:
FROM ubuntu:22.04
WORKDIR /workspace
COPY /sandbox-api /usr/local/bin/sandbox-api
RUN apt-get update && apt-get install -y curl git
RUN curl -fsSL https://bun.sh/install | bash
RUN pip install numpy pandas
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
Image building methods mapping
All Modal image building methods map to standard Dockerfile instructions in a Blaxel template:
| Modal Image Method | Blaxel Dockerfile Equivalent |
|---|---|
.debian_slim() |
FROM python:3.11-slim |
.from_registry("img") |
FROM img |
.from_dockerfile("./Dockerfile") |
Use the Dockerfile directly as the Blaxel template |
.pip_install("numpy") |
RUN pip install numpy |
.pip_install_from_requirements("r.txt") |
COPY requirements.txt . && RUN pip install -r requirements.txt |
.pip_install_from_pyproject("p.toml") |
COPY pyproject.toml . && RUN pip install . |
.poetry_install_from_file(...) |
COPY pyproject.toml poetry.lock . && RUN poetry install |
.uv_pip_install("numpy") |
RUN uv pip install numpy |
.uv_sync() |
COPY pyproject.toml uv.lock . && RUN uv sync |
.apt_install("curl") |
RUN apt-get update && apt-get install -y curl |
.micromamba_install("numpy") |
RUN micromamba install numpy |
.add_local_file("f", "/app/f") |
COPY f /app/f |
.add_local_dir("./src", "/app/src") |
COPY ./src /app/src |
.run_commands("echo hi") |
RUN echo hi |
.run_function(fn) |
N/A (use RUN with a script instead) |
.env({"KEY": "val"}) |
ENV KEY=val |
.workdir("/app") |
WORKDIR /app |
.entrypoint(["python"]) |
ENTRYPOINT ["python"] |
.shell("/bin/bash") |
SHELL ["/bin/bash", "-c"] |
.cmd(["serve"]) |
CMD ["serve"] |
Image pre-warming: Modal's image.build(app) eagerly caches images before sandbox creation. The Blaxel equivalent is bl deploy, which builds and pushes the template image to Blaxel's private registry. Subsequent sandbox creation with that image is instant.
Command execution
Modal uses sandbox.exec(['bash', '-c', command]) with stdout/stderr streams. Blaxel uses a single process.exec() call.
TypeScript:
// BEFORE (Modal)
const result = await sandbox.exec(['bash', '-c', 'echo hello'])
const stdout = await result.stdout.readText()
const stderr = await result.stderr.readText()
const exitCode = await result.wait()
// With env vars and working directory (manual shell construction):
const envStr = Object.entries(envVars).map(([k, v]) => `export ${k}="${v}"`).join(' && ')
const fullCommand = `${envStr} && cd /app && npm install`
const result = await sandbox.exec(['bash', '-c', fullCommand])
// AFTER (Blaxel)
const result = await sandbox.process.exec({
command: 'echo hello',
waitForCompletion: true,
})
// With env vars and working directory (native support):
const result = await sandbox.process.exec({
command: 'npm install',
workingDir: '/app',
env: { NODE_ENV: 'production' },
waitForCompletion: true,
})
Python:
# BEFORE (Modal)
process = sandbox.exec("bash", "-c", "echo hello")
stdout = process.stdout.read()
stderr = process.stderr.read()
process.wait()
# With env vars (manual shell construction):
process = sandbox.exec("bash", "-c", "export NODE_ENV=production && cd /app && npm install")
# AFTER (Blaxel)
result = await sandbox.process.exec({
"command": "echo hello",
"wait_for_completion": True,
})
# With env vars and working directory (native support):
result = await sandbox.process.exec({
"command": "npm install",
"working_dir": "/app",
"env": {"NODE_ENV": "production"},
"wait_for_completion": True,
})
Long-running processes (dev servers)
TypeScript:
// BEFORE (Modal) - must redirect to temp file and poll
const bgCommand = `export PATH=... && cd /app && npm run dev > /tmp/output.log 2>&1 & echo $! > /tmp/pid.txt`
await sandbox.exec(['bash', '-c', bgCommand])
// ... poll /tmp/output.log for readiness ...
// ... kill process via: sandbox.exec(['bash', '-c', 'kill $(cat /tmp/pid.txt)'])
// AFTER (Blaxel) - native background mode with port waiting
const devServer = await sandbox.process.exec({
name: 'dev-server',
command: 'npm run dev -- --host 0.0.0.0',
workingDir: '/app',
waitForPorts: [3000], // returns once port 3000 is open
})
// Later: await sandbox.process.kill('dev-server')
Python:
# BEFORE (Modal) - must redirect to temp file and poll
bg_command = "export PATH=... && cd /app && npm run dev > /tmp/output.log 2>&1 & echo $! > /tmp/pid.txt"
sandbox.exec("bash", "-c", bg_command)
# ... poll /tmp/output.log for readiness ...
# AFTER (Blaxel) - native background mode with port waiting
dev_server = await sandbox.process.exec({
"name": "dev-server",
"command": "npm run dev -- --host 0.0.0.0",
"working_dir": "/app",
"wait_for_ports": [3000],
})
# Later: await sandbox.process.kill("dev-server")
Log streaming / Process output
Modal uses pipe-based stdout/stderr streams on the sandbox entrypoint or on exec() results. Blaxel captures output via callbacks, log retrieval, or stream subscriptions.
TypeScript:
// BEFORE (Modal) - pipe-based streams
const result = await sandbox.exec(['bash', '-c', 'echo hello && echo err >&2'])
const stdout = await result.stdout.readText()
const stderr = await result.stderr.readText()
const exitCode = await result.wait()
// Entrypoint streams:
await sandbox.stdin.writeText('input\n')
const output = await sandbox.stdout.readText()
// AFTER (Blaxel) - callbacks on exec
const result = await sandbox.process.exec({
command: 'echo hello && echo err >&2',
onLog: (log) => console.log('[LOG]', log),
onStdout: (out) => process.stdout.write(out),
onStderr: (err) => process.stderr.write(err),
waitForCompletion: true,
})
// result.stdout, result.stderr, result.exitCode available after completion
// Stream logs from a running process:
const handle = sandbox.process.streamLogs('my-process', {
onLog: (log) => console.log(log),
onStdout: (out) => process.stdout.write(out),
onStderr: (err) => process.stderr.write(err),
onError: (err) => console.error(err),
})
// Wait for the process to finish before closing the stream:
await handle.wait()
// Retrieve logs after the fact:
const logs = await sandbox.process.logs('my-process') // all
const stdout = await sandbox.process.logs('my-process', 'stdout')
const stderr = await sandbox.process.logs('my-process', 'stderr')
Python:
# BEFORE (Modal) - pipe-based streams
process = sandbox.exec("bash", "-c", "echo hello && echo err >&2")
stdout = process.stdout.read()
stderr = process.stderr.read()
process.wait()
# AFTER (Blaxel) - callbacks on exec
result = await sandbox.process.exec({
"command": "echo hello && echo err >&2",
"on_log": lambda log: print("[LOG]", log),
"on_stdout": lambda out: print(out, end=""),
"on_stderr": lambda err: print(err, end="", file=sys.stderr),
"wait_for_completion": True,
})
# result.stdout, result.stderr, result.exit_code available
# Stream logs from a running process:
handle = await sandbox.process.stream_logs("my-process", {
"on_log": lambda log: print(log),
})
# Wait for the process to finish before closing the stream:
await handle.wait()
# Retrieve logs after the fact:
logs = await sandbox.process.logs("my-process") # all
stdout = await sandbox.process.logs("my-process", "stdout")
stderr = await sandbox.process.logs("my-process", "stderr")
Note: Blaxel does not support
stdinwrites or pseudo-terminal (pty) mode. There is no equivalent to Modal'spty: trueparameter.
Secrets
Modal has vault-based secrets (Secret.from_name(), Secret.from_dict(), Secret.from_dotenv()). Blaxel uses environment variables with an optional secret: true flag.
TypeScript:
// BEFORE (Modal) - vault-based secrets
const secret = await modal.Secret.fromName('my-secret')
const ephemeralSecret = modal.Secret.fromDict({ API_KEY: 'sk-...' })
const dotenvSecret = modal.Secret.fromDotenv()
const sandbox = await client.sandboxes.create(app, image, {
secrets: [secret, ephemeralSecret],
})
// Per-exec secrets:
const result = await sandbox.exec(['bash', '-c', 'echo $API_KEY'], {
secrets: [ephemeralSecret],
})
// AFTER (Blaxel) - environment variables with secret flag
const sandbox = await SandboxInstance.createIfNotExists({
name: 'my-sandbox',
image: 'blaxel/base-image:latest',
envs: [
{ name: 'API_KEY', value: 'sk-...', secret: true }, // marked as secret
{ name: 'PUBLIC_VAR', value: 'visible' },
],
})
// Per-exec env vars:
const result = await sandbox.process.exec({
command: 'echo $API_KEY',
env: { API_KEY: 'sk-...' },
waitForCompletion: true,
})
Python:
# BEFORE (Modal) - vault-based secrets
secret = modal.Secret.from_name("my-secret")
ephemeral_secret = modal.Secret.from_dict({"API_KEY": "sk-..."})
dotenv_secret = modal.Secret.from_dotenv()
sandbox = app.spawn_sandbox(image=image, secrets=[secret, ephemeral_secret])
# AFTER (Blaxel) - environment variables with secret flag
sandbox = await SandboxInstance.create_if_not_exists({
"name": "my-sandbox",
"image": "blaxel/base-image:latest",
"envs": [
{"name": "API_KEY", "value": "sk-...", "secret": True},
{"name": "PUBLIC_VAR", "value": "visible"},
],
})
# Per-exec env vars:
result = await sandbox.process.exec({
"command": "echo $API_KEY",
"env": {"API_KEY": "sk-..."},
"wait_for_completion": True,
})
Note: Blaxel does not have a vault-style secret management API. For production, use the Variables-and-secrets page in the Blaxel Console. The SDK auto-detects
.envfiles for authentication (similar toSecret.from_dotenv()).
Connect tokens / Sessions
Modal's createConnectToken() provides authenticated access to a sandbox. Blaxel has two equivalents: preview tokens (for HTTP access to sandbox services) and sessions (for SDK-based sandbox access from frontends).
TypeScript:
// BEFORE (Modal) - connect token for sandbox access
const { url, token } = await sandbox.createConnectToken({ userMetadata: 'user-123' })
// AFTER (Blaxel) - Option 1: Preview token for HTTP access
const preview = await sandbox.previews.createIfNotExists({
metadata: { name: 'app-preview' },
spec: { port: 3000, public: false },
})
const token = await preview.tokens.create(new Date(Date.now() + 3600000)) // 1 hour
const accessUrl = `${preview.spec?.url}?bl_preview_token=${token.value}`
// AFTER (Blaxel) - Option 2: Session for SDK-based frontend access
const session = await sandbox.sessions.create({
expiresAt: new Date(Date.now() + 3600000),
})
// Pass session token to frontend, then reconstruct the SDK client:
const frontendSandbox = await SandboxInstance.fromSession(session.token)
Python:
# BEFORE (Modal)
result = sandbox.create_connect_token(user_metadata="user-123")
# AFTER (Blaxel) - Preview token for HTTP access
preview = await sandbox.previews.create_if_not_exists({
"metadata": {"name": "app-preview"},
"spec": {"port": 3000, "public": False},
})
token = await preview.tokens.create(datetime.now() + timedelta(hours=1))
access_url = f"{preview.spec.url}?bl_preview_token={token.value}"
# AFTER (Blaxel) - Session for SDK-based frontend access
session = await sandbox.sessions.create({
"expires_at": datetime.now() + timedelta(hours=1),
})
# Pass session token to frontend
Filesystem operations
Modal uses low-level file handles (open, read, write, close) or newer ls/mkdir/rm methods. Blaxel has high-level methods throughout.
| Operation | Modal | Blaxel |
|---|---|---|
| Write file | file = sandbox.open(path, 'w') → file.write(encoded) → file.close() |
sandbox.fs.write(path, content) |
| Read file | file = sandbox.open(path, 'r') → file.read() → file.close() |
sandbox.fs.read(path) |
| List files | sandbox.ls(path) or sandbox.exec(['ls', path]) |
sandbox.fs.ls(path) |
| Create dir | sandbox.mkdir(path) or sandbox.exec(['mkdir', '-p', path]) |
sandbox.fs.mkdir(path) |
| Delete file | sandbox.rm(path) |
sandbox.fs.rm(path) / sandbox.fs.rm(path, { recursive: true }) |
| Copy file | sandbox.exec(['cp', ...]) |
sandbox.fs.cp(src, dest) |
| Watch files | sandbox.watch(path) (Alpha) |
sandbox.fs.watch(path, callback, { withContent: true, ignore: ['node_modules'] }) |
| Fuzzy search | N/A | sandbox.fs.search(query, path) |
| Write tree | N/A | sandbox.fs.writeTree(files, basePath) / write_tree(files, base) |
| Write binary | file.write(bytes) |
sandbox.fs.writeBinary(path, buffer) / write_binary(path, bytes) |
| Read binary | file.read() |
sandbox.fs.readBinary(path) / read_binary(path) |
| Search content | N/A | sandbox.fs.grep(pattern, path) |
| Find files | N/A | sandbox.fs.find(path, { patterns: ['*.ts'] }) |
TypeScript:
// BEFORE (Modal) - low-level file handles
const file = await sandbox.open('/app/file.txt', 'w')
await file.write(new TextEncoder().encode('hello'))
await file.close()
const rFile = await sandbox.open('/app/file.txt', 'r')
const data = await rFile.read()
await rFile.close()
const content = new TextDecoder().decode(data)
const p = await sandbox.exec(['ls', '/app'])
const listing = await p.stdout.readText()
// AFTER (Blaxel) - high-level one-liners
await sandbox.fs.write('/app/file.txt', 'hello')
const content = await sandbox.fs.read('/app/file.txt')
const { subdirectories, files } = await sandbox.fs.ls('/app')
// Write multiple files at once:
await sandbox.fs.writeTree([
{ path: 'src/index.ts', content: 'console.log("hello")' },
{ path: 'package.json', content: '{"name":"app"}' },
], '/app')
// Delete files:
await sandbox.fs.rm('/app/temp.txt')
await sandbox.fs.rm('/app/old-dir', { recursive: true })
// Copy files:
await sandbox.fs.cp('/app/file.txt', '/backup/file.txt')
// Watch for file changes:
const handle = sandbox.fs.watch('/app/src', (event) => {
console.log(event.op, event.path)
}, { withContent: true, ignore: ['node_modules', '.git'] })
// Later: handle.close()
Python:
# BEFORE (Modal) - low-level file handles
with sandbox.open("/app/file.txt", "w") as f:
f.write(b"hello")
with sandbox.open("/app/file.txt", "r") as f:
content = f.read().decode()
process = sandbox.exec("ls", "/app")
listing = process.stdout.read()
# AFTER (Blaxel) - high-level one-liners
await sandbox.fs.write("/app/file.txt", "hello")
content = await sandbox.fs.read("/app/file.txt")
listing = await sandbox.fs.ls("/app")
# Write multiple files at once:
await sandbox.fs.write_tree([
{"path": "src/index.ts", "content": 'console.log("hello")'},
{"path": "package.json", "content": '{"name":"app"}'},
], "/app")
# Delete files:
await sandbox.fs.rm("/app/temp.txt")
await sandbox.fs.rm("/app/old-dir", recursive=True)
# Copy files:
await sandbox.fs.cp("/app/file.txt", "/backup/file.txt")
# Watch for file changes:
def on_change(event):
print(event.op, event.path)
handle = sandbox.fs.watch("/app/src", on_change, {
"with_content": True,
"ignore": ["node_modules", ".git"]
})
# Later: handle["close"]()
Preview URLs / Tunnels
Modal uses tunnels for exposing sandbox ports. Blaxel has dedicated preview URL management.
TypeScript:
// BEFORE (Modal) - tunnels for port access
const sandbox = await client.sandboxes.create(app, image, {
encrypted_ports: [3000],
})
const tunnels = await sandbox.tunnels()
const url = tunnels.get(3000)?.url
// AFTER (Blaxel) - dedicated preview URL with access control
const sandbox = await SandboxInstance.createIfNotExists({
name: 'my-sandbox',
image: 'blaxel/node:latest',
region: 'us-pdx-1',
memory: 2048,
ports: [{ target: 3000, protocol: 'HTTP' }],
})
const preview = await sandbox.previews.createIfNotExists({
metadata: { name: 'app-preview' },
spec: { port: 3000, public: true },
})
const url = preview.spec?.url // https://xxxx.preview.bl.run
Python:
# BEFORE (Modal) - tunnels for port access
sandbox = app.spawn_sandbox(image=image, encrypted_ports=[3000])
tunnels = sandbox.tunnels()
url = tunnels[3000].url
# AFTER (Blaxel) - dedicated preview URL with access control
sandbox = await SandboxInstance.create_if_not_exists({
"name": "my-sandbox",
"image": "blaxel/node:latest",
"region": "us-pdx-1",
"memory": 2048,
"ports": [{"target": 3000, "protocol": "HTTP"}],
})
preview = await sandbox.previews.create_if_not_exists({
"metadata": {"name": "app-preview"},
"spec": {"port": 3000, "public": True},
})
url = preview.spec.url # https://xxxx.preview.bl.run
Snapshot / Pause / Resume
Modal uses filesystem snapshots for pause/resume. Blaxel handles this automatically with scale-to-zero.
TypeScript:
// BEFORE (Modal) - manual snapshot and restore
const snapshotImage = await sandbox.snapshotFilesystem()
await sandbox.terminate()
// ... later ...
const newSandbox = await client.sandboxes.create(app, snapshotImage, {
regions: ['us-west-2'],
})
// AFTER (Blaxel) - automatic: sandboxes auto-pause after ~5s inactivity
// and resume in <25ms on the next API call. No explicit snapshot needed.
// State (memory + filesystem) is preserved automatically.
const sandbox = await SandboxInstance.get('my-sandbox')
Python:
# BEFORE (Modal) - manual snapshot and restore
snapshot_image = sandbox.snapshot_filesystem()
sandbox.terminate()
# ... later ...
new_sandbox = app.spawn_sandbox(image=snapshot_image)
# AFTER (Blaxel) - automatic, just reconnect by name
sandbox = await SandboxInstance.get("my-sandbox")
Directory snapshots: Modal's
snapshotDirectory()/mountImage()for sharing directory state between sandboxes has no direct Blaxel equivalent. Blaxel auto-preserves sandbox state across scale-to-zero. To share pre-built directory state across multiple sandboxes, use volume templates (bl new volume-template) to create volumes pre-populated with files.
Volumes
TypeScript:
// BEFORE (Modal) - mount volumes and cloud buckets
const sandbox = await client.sandboxes.create(app, image, {
mounts: [modal.Mount.from_local_dir('./data', remote_path='/data')],
volumes: { '/persistent': myVolume },
})
await sandbox.reloadVolumes()
// AFTER (Blaxel) - attach volumes at creation
import { VolumeInstance, SandboxInstance } from '@blaxel/core'
const volume = await VolumeInstance.createIfNotExists({
name: 'my-volume',
size: 1024,
region: 'us-pdx-1',
})
const sandbox = await SandboxInstance.createIfNotExists({
name: 'my-sandbox',
image: 'blaxel/base-image:latest',
region: 'us-pdx-1',
memory: 2048,
volumes: [{ name: 'my-volume', mountPath: '/data', readOnly: false }],
})
Python:
# BEFORE (Modal) - mount volumes
volume = modal.Volume.from_name("my-volume", create_if_missing=True)
sandbox = app.spawn_sandbox(image=image, volumes={"/data": volume})
# AFTER (Blaxel) - attach volumes at creation
from blaxel.core import VolumeInstance, SandboxInstance
volume = await VolumeInstance.create_if_not_exists({
"name": "my-volume",
"size": 1024,
"region": "us-pdx-1",
})
sandbox = await SandboxInstance.create_if_not_exists({
"name": "my-sandbox",
"image": "blaxel/base-image:latest",
"region": "us-pdx-1",
"memory": 2048,
"volumes": [{"name": "my-volume", "mount_path": "/data", "read_only": False}],
})
reloadVolumes()not needed: Modal requiressandbox.reload_volumes()/sb.reloadVolumes()to sync volume changes. Blaxel volumes are persistent block storage mounted directly — changes are immediately visible without reload.
Ephemeral volumes: Modal's
modal.volumes.ephemeral()for temporary shared storage between sandboxes has no Blaxel equivalent. Blaxel only supports persistent named volumes. Workaround: create a named volume and delete it when done, or use the sandbox filesystem (data persists across auto-pause/resume).
Sandbox lifecycle
| Operation | Modal | Blaxel |
|---|---|---|
| Terminate | sandbox.terminate() |
sandbox.delete() |
| Detach (release connection) | sandbox.detach() |
N/A (stateless API calls) |
| Wait for completion | sandbox.wait() |
sandbox.process.exec({ waitForCompletion: true }) (max 60s) or sandbox.process.wait('name', { maxWait: 300000 }) for longer |
| Exit code | sandbox.returncode / result.wait() |
result.exitCode on process response, or (await sandbox.process.get('name')).exitCode |
| Poll exit code | sandbox.poll() |
sandbox.process.get('name') — check .status and .exitCode |
| List sandboxes | Sandbox.list(app, tags={...}) |
SandboxInstance.list() (SDK) or bl get sandboxes (CLI). Labels in metadata for filtering |
| Get by ID | Sandbox.from_id(id) |
SandboxInstance.get('name') (Blaxel uses human-readable names, not opaque IDs) |
| Get by name | Sandbox.from_name(name, app) |
SandboxInstance.get('name') |
| Tags / Labels (read) | sandbox.get_tags() |
sandbox.metadata.labels |
| Tags / Labels (write) | sandbox.set_tags({...}) |
Set labels at creation; update via SandboxInstance.updateMetadata('name', { labels: {...} }) |
| Process management | result.wait() / result.terminate() |
sandbox.process.get('name'), .stop('name') (SIGTERM), .kill('name') (SIGKILL), .list(), .wait('name') |
TypeScript:
// BEFORE (Modal)
await sandbox.terminate()
// AFTER (Blaxel)
await sandbox.delete()
Python:
# BEFORE (Modal)
sandbox.terminate()
# AFTER (Blaxel)
await sandbox.delete()
Key differences
- No client/app boilerplate: Modal requires
ModalClient→App→Image→Sandbox(TS) ormodal.App()→Image→spawn_sandbox()(Python). Blaxel uses a singleSandboxInstance.createIfNotExists()/create_if_not_exists()call - No programmatic image building: Modal builds images via chained SDK methods (
fromRegistry().dockerfileCommands(),.pip_install(),.apt_install()). Blaxel uses standard Dockerfiles deployed withbl deploy - No GPU support: Modal supports GPU-accelerated sandboxes (T4, A10G, A100, H100, etc.). Blaxel sandboxes are CPU-only
- No entrypoint command at creation: Modal allows
command: [...]at sandbox creation. Blaxel requires creating the sandbox first, then callingprocess.exec() - High-level filesystem API: Modal uses low-level file handles (
open/read/write/close). Blaxel hasfs.write(),fs.read(),fs.ls(),fs.rm(),fs.cp(),fs.writeTree()/write_tree() - Log streaming model: Modal uses pipe-based stdout/stderr streams. Blaxel uses callbacks (
onLog,onStdout,onStderr) onprocess.exec(),process.streamLogs(), andprocess.logs()for retrieval - Native env vars and working dir: Modal requires manual shell string construction for env vars. Blaxel accepts
envandworkingDir/working_diras parameters - Environment variables as secrets: Modal has vault-based
Secret.from_name(). Blaxel usesenvs: [{ name: 'KEY', value: 'val', secret: true }]— no separate vault API - Auto scale-to-zero: Blaxel sandboxes auto-pause after ~5s of inactivity and resume in <25ms. No manual snapshots needed. Modal requires explicit
snapshotFilesystem()/snapshot_filesystem()+terminate(). For idle-based deletion, Blaxel uses lifecycle expiration policies (ttl-idle) - Region format: Modal uses cloud provider regions (
us-west-2,us-central1). Blaxel usesus-pdx-1 - Ports declared at creation: Blaxel requires ports at sandbox creation time; they cannot be added later. Ports 80, 443, 8080 are reserved
- Preview URLs vs Tunnels: Modal uses
encrypted_ports+tunnels(). Blaxel has dedicatedpreviews.createIfNotExists()/create_if_not_exists()with public/private access control and token-based authentication - Named sandboxes: Blaxel supports
createIfNotExists/create_if_not_existswith anamefor idempotent creation (globally unique, not scoped to an app). Blaxel uses human-readable names, not opaque IDs - Process naming and management: Blaxel processes can be named for later reference (
get,stop,kill,list,wait,logsby name). Modal returnsContainerProcessobjects - Dev server binding: Dev servers must bind to
0.0.0.0(notlocalhost) for preview URLs to work in Blaxel - Async Python: Blaxel Python SDK is async-first (all calls use
await); Modal Python SDK is sync by default - ~50% memory reserved for tmpfs: Blaxel uses half of sandbox memory for the in-memory filesystem. Use volumes for extra storage
- Proxy/VPC: Modal uses
modal.proxies.fromName(...). Blaxel uses VPC + egress gateway vianetwork: { vpcName: '...', egressGatewayName: '...' }at sandbox creation
Complete migration example
TypeScript
BEFORE (Modal):
import { ModalClient } from 'modal'
const client = new ModalClient()
const app = await client.apps.fromName('my-app', { createIfMissing: true })
const image = client.images.fromRegistry('python:3.11-slim')
const sandbox = await client.sandboxes.create(app, image, {
regions: ['us-west-2'],
memoryMiB: 2048,
cpu: 1,
timeoutMs: 1000 * 60 * 15,
encrypted_ports: [3000],
})
// Write a file
const file = await sandbox.open('/app/index.js', 'w')
await file.write(new TextEncoder().encode('console.log("hello")'))
await file.close()
// Run a command
const result = await sandbox.exec(['bash', '-c', 'node /app/index.js'])
const stdout = await result.stdout.readText()
await result.wait()
console.log(stdout)
// Get tunnel URL
const tunnels = await sandbox.tunnels()
console.log(`Preview: ${tunnels.get(3000)?.url}`)
await sandbox.terminate()
AFTER (Blaxel):
import { SandboxInstance } from '@blaxel/core'
const sandbox = await SandboxInstance.createIfNotExists({
name: 'my-sandbox',
image: 'blaxel/node:latest',
region: 'us-pdx-1',
memory: 2048,
ports: [{ target: 3000, protocol: 'HTTP' }],
envs: [{ name: 'NODE_ENV', value: 'production' }],
})
// Write a file
await sandbox.fs.write('/app/index.js', 'console.log("hello")')
// Run a command
const result = await sandbox.process.exec({
command: 'node /app/index.js',
workingDir: '/app',
waitForCompletion: true,
})
// Get a shareable preview URL
const preview = await sandbox.previews.createIfNotExists({
metadata: { name: 'app-preview' },
spec: { port: 3000, public: true },
})
console.log(`Preview: ${preview.spec?.url}`)
await sandbox.delete()
Python
BEFORE (Modal):
import modal
app = modal.App("my-app")
image = modal.Image.debian_slim().pip_install("numpy")
sandbox = app.spawn_sandbox(
image=image,
timeout=900,
cpu=1,
memory=2048,
encrypted_ports=[3000],
)
# Write a file
with sandbox.open("/app/main.py", "w") as f:
f.write(b'print("hello")')
# Run a command
process = sandbox.exec("bash", "-c", "python3 /app/main.py")
stdout = process.stdout.read()
process.wait()
print(stdout)
# Get tunnel URL
tunnels = sandbox.tunnels()
print(f"Preview: {tunnels[3000].url}")
sandbox.terminate()
AFTER (Blaxel):
import asyncio
from blaxel.core import SandboxInstance
async def main():
sandbox = await SandboxInstance.create_if_not_exists({
"name": "my-sandbox",
"image": "blaxel/py-app:latest",
"region": "us-pdx-1",
"memory": 2048,
"ports": [{"target": 3000, "protocol": "HTTP"}],
"envs": [{"name": "NODE_ENV", "value": "production"}],
})
# Write a file
await sandbox.fs.write("/app/main.py", 'print("hello")')
# Run a command
result = await sandbox.process.exec({
"command": "python3 /app/main.py",
"working_dir": "/app",
"wait_for_completion": True,
})
# Get a shareable preview URL
preview = await sandbox.previews.create_if_not_exists({
"metadata": {"name": "app-preview"},
"spec": {"port": 3000, "public": True},
})
print(f"Preview: {preview.spec.url}")
await sandbox.delete()
asyncio.run(main())
Resources
- Blaxel docs: https://docs.blaxel.ai
- Blaxel sandbox images: https://github.com/blaxel-ai/sandbox/tree/main/hub
- Blaxel sandbox templates (custom Dockerfiles): https://docs.blaxel.ai/Sandboxes/Sandbox-Templates
- Modal sandbox docs: https://modal.com/docs/guide/sandboxes
- Modal sandbox networking: https://modal.com/docs/guide/sandbox-networking
- SDK reference (TypeScript):
@blaxel/core— see ./references/sdk-typescript.md - SDK reference (Python):
blaxel— see ./references/sdk-python.md