Replace the e2b SDK with the freestyle SDK one surface at a time: sandbox
lifecycle, commands, files, terminals, templates, preview URLs, and client
access. Most calls have a direct equivalent. The parts that do not are the
five-minute timeout heartbeat, streaming command output, and the filesystem
watcher, and this guide gives a working replacement for each.
This guide covers the E2B JavaScript SDK v2 (Sandbox, commands, files,
pty, and the Template builder). It does not cover the Code Interpreter or
Desktop SDKs; see Jupyter and
GNOME for those workloads.
Why Migrate
Freestyle VMs are full Linux virtual machines designed for long-running, complex tasks. E2B sandboxes are a good fit for short, retryable runs; the differences below matter once a workspace lives for hours or weeks.
- Faster at the tail. VMs provision in milliseconds, with p99s under 400 ms, and a base snapshot boots in about a second. Your users see the p99, not the median, and the p99 is what a full VM booted from a memory snapshot improves.
- No running-time ceiling. An E2B sandbox must pause or die at its timeout, and a run tops out at 24 hours. A Freestyle VM runs until you pause, stop, or delete it. Lifecycle policy is a set of optional windows, not a deadline you have to keep extending.
- Pause is the resting state. Pausing preserves memory and running processes, holds no compute reservation, and does not count against your concurrent-VM limit. Read files and browse directories while compute stays paused. Commands, file writes, and traffic to its domain wake the VM automatically.
- Snapshots are memory and disk. A snapshot captures a running VM exactly, with sub-millisecond interruption, so a dev server started once is already serving in every VM booted from it. That is what E2B’s start command does at build time, without a separate build system.
- Higher-quality virtualization. Freestyle runs full hardware virtualization on bare metal, not a trimmed microVM: systemd is pid 1, Docker runs from boot, and nested virtualization, FUSE, eBPF, WireGuard, and the whole Linux networking stack work. If it runs on an EC2 instance, it runs in a Freestyle VM, so workloads that hit the walls of a sandbox, such as Docker Compose stacks, Kubernetes with k3s, Android emulators, and kernel-level tooling, need no workarounds.
- Bigger VMs. Public plans go to 32 vCPU, 64 GiB of memory, and 256 GB
of disk per VM, with
freestyle/ubuntu-3xlat 64 vCPU and 128 GiB for custom limits, and a VM can be resized live while it runs. - Higher limits. Pro allows 400 concurrent VMs, 4,000 saved VMs, and 12,000 snapshots, and paused VMs do not count toward concurrency. See Pricing and Limits for every plan, and talk to sales when you need more.
- Complete networking APIs. Every VM has a stable public IPv6 address and a firewall that describes exactly what it may reach. Put VMs on private VPCs, connect your own machines and production networks over WireGuard tunnels, route a domain to any port, reach VMs by name, gate public traffic with forward auth, send egress out through your own proxy, and inject secrets into outbound requests with TLS rules, all from one API instead of an allow list and a public URL.
- Built for multiple customers. Key VMs by user with slugs, hand each client a scoped identity token, isolate tenants with VPCs, and hold provider secrets at the edge with TLS rules.
Map Your Current Setup
Inventory every Sandbox, commands, files, pty, and Template call your
code makes, then map each to its Freestyle equivalent. The rows marked
adapter have no one-line replacement; each has a section below.
Expect the Freestyle side of the table to be shorter. E2B’s SDK grows a
purpose-built method for each job: a declarative template builder with its
own copy, run, and ready-command steps, a background command handle, a batch
write, a directory watcher, a git module, signed upload URLs. Freestyle keeps
the API to the primitives a machine actually has: create and snapshot a VM,
run a command, move bytes in and out, open a terminal, and describe its
network. There is no builder DSL; the recommended way to build an image is to
boot a base snapshot, set it up with the same commands you would run on any
Linux box, and snapshot it, and
Rebuild Templates As Snapshots shows that
in full. The trade is deliberate. A builder can only express what its authors
anticipated, while a shell script, a systemd unit, or a Dockerfile you already
maintain can express anything, and running it yourself leaves the ordering,
caching, error handling, and secrets under your control rather than behind a
provider abstraction. The same holds for the other rows: where E2B ships a
convenience, Freestyle expects you to compose it from exec, fs, and pty
in a few lines you own.
The git module is a small example: E2B’s SDK assumes every sandbox has Git.
A Freestyle VM does not have to, so there is no vm.git; run git with
exec and let the edge inject the credentials.
| E2B | Freestyle |
|---|---|
Sandbox.create(template, opts) | freestyle.vms.create({ snapshotId, firewall, ... }) |
Sandbox.connect(sandboxId) | freestyle.vms.ref(vmId); commands and file writes wake a paused VM, file reads leave it paused |
sandbox.sandboxId | vmId, or the slug you chose |
metadata, Sandbox.list({ query }) | metadata, freestyle.vms.list({ metadata, state }) |
sandbox.getInfo(), isRunning() | vm.data() and its state |
timeoutMs, setTimeout() | idleTimeoutSeconds, maxRunSeconds, or your own vm.pause() (adapter) |
lifecycle: { onTimeout: "pause" } | The default: nothing deletes a VM unless autoDeleteSeconds or ttlSeconds says so |
sandbox.pause() | vm.pause() |
sandbox.kill() | vm.delete() |
sandbox.fork(), createSnapshot() | vm.snapshot(), then vms.create({ snapshotId }) |
commands.run(cmd, { cwd, envs, user, timeoutMs }) | vm.exec({ command, env, linuxUser, timeoutMs }) |
commands.run(cmd, { background, onStdout }) | PTY with exec, or nohup plus a log file (adapter) |
commands.connect(pid), kill(pid) | vm.pty.attach({ session }), vm.pty.close() |
pty.create(), sendInput(), resize(), kill() | vm.pty.open(), session.write(), resize(), pty.close() |
files.read(), write(), list(), getInfo() | vm.fs.readFile(), writeFile(), readDir(), stat() |
files.write([...entries]) | Promise.all of writeFile calls |
files.read(path, { format: "stream" }) | vm.fs.readFileStream() |
files.rename() | vm.exec("mv ...") |
files.watchDir() | inotifywait in the guest over a PTY (adapter) |
uploadUrl(), downloadUrl() | Your server proxies vm.fs, or the client uses an identity token |
sandbox.getHost(port) | A TLS rule from a style.dev name or your domain to the port |
allowPublicTraffic: false, traffic token | Forward auth on the ingress rule |
allowInternetAccess, allowOut | Firewall rules; egress is denied until allowed |
envs at create | env on each exec, or bake into the snapshot |
envd access token for clients | Identity tokens |
Template().fromImage().runCmd()..., Template.build() | Boot a base snapshot, set it up, vm.snapshot() |
setStartCmd(cmd, readyCmd) | Start a systemd unit, wait for it, snapshot while running |
template:tag | Snapshot slug |
| Secrets injected into requests | TLS transforms |
Create, Find, And Reconnect
Sandbox.create() becomes vms.create(). Two things are different at the call
site. firewall is required, because a VM reaches nothing it has not been
allowed to, where an E2B sandbox has Internet access by default. And a slug
gives the VM a name you choose, so a workspace can be addressed by your own
identifier instead of a stored provider ID.
Install the SDK and set FREESTYLE_API_KEY in your backend. Then:
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
// Sandbox.create("my-template", { metadata: { workspaceId }, timeoutMs })
const { vm, vmId } = await freestyle.vms.create({
snapshotId: "my-template", // your snapshot's slug; omit for freestyle/ubuntu
slug: `workspace-${workspaceId}`,
metadata: { workspaceId, tenant: "acme" },
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
});
console.log(vmId); // vm-…
Sandbox.connect(sandboxId) becomes a handle. vms.ref() makes no network
call, and a paused VM wakes on its next exec or file write, or on the
first keystroke into one of its PTY sessions, which is what
autoResume: true did for you on E2B:
const vm = freestyle.vms.ref(`workspace-${workspaceId}`); // or the vm-… id
const result = await vm.exec("uptime");
File reads, directory listings, and metadata checks leave the VM paused, so users can inspect a workspace without starting compute.
To find a VM you did not store an ID for, filter by metadata. E2B’s
Sandbox.list({ query: { metadata } }) takes an object; Freestyle takes
key:value pairs in one string, and state is a single value rather than a
list:
const { vms } = await freestyle.vms.list({ metadata: `workspaceId:${workspaceId}` });
const existing = vms.find((candidate) => candidate.state !== "stopped");
If your create path is idempotent on a slug, reassignSlug: true takes the
slug from a VM that still holds it; without it, a taken slug is an error, which
is usually the check you want.
getInfo() becomes vm.data(). Its state is one of starting, running,
pausing, paused, or stopped, and createdAt, lastNetworkActivity, and
metadata are on the same record. A missing VM is a FreestyleApiError with
status 404; map it to whatever your E2B NotFoundError handling recreated.
sandbox.kill() becomes vm.delete(). Deleting also deletes the firewall and
TLS rules that name the VM.
Replace The Timeout Heartbeat
The most common E2B integration pattern is a five-minute timeoutMs extended
by setTimeout() from a client heartbeat, with onTimeout: "pause" so the
sandbox survives. Do not port the heartbeat. A Freestyle VM has no deadline,
so there is nothing to extend; instead you choose what should pause it.
Idle timeout. idleTimeoutSeconds pauses a VM after that many seconds
without network activity. Traffic to its domain counts, and so does anything
typed into a PTY. Output alone does not, and neither does CPU work with no
traffic, so a VM that is only computing can idle out. Traffic to a paused VM’s
domain wakes it.
const { vm } = await freestyle.vms.create({
snapshotId: "my-template",
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
idleTimeoutSeconds: 600,
});
await vm.update({ idleTimeoutSeconds: -1 }); // remove it later
Run caps. maxRunSeconds pauses a VM once one run has lasted that long,
whatever it is doing, and starting it again gives a fresh budget. ttlSeconds
deletes it a fixed time after creation. autoDeleteSeconds deletes it after it
has sat stopped or paused that long, and 0 makes it ephemeral, deleted the
moment it stops. None of these are set unless you set them; some plans cap how
long an unused VM is kept, and the cap shows in data().autoDeleteSeconds.
Your own policy. If your application already tracks sessions or leases, keep that logic and make it the one that decides. Pause when the last lease expires, and start when a client comes back:
// When the last lease on a workspace expires:
await vm.pause();
// When a client reopens it:
if ((await vm.data()).state === "paused") await vm.start();
start() resumes a paused VM with its processes and memory intact, and boots
a stopped one fresh. Pause and resume are quick, so pausing on lease expiry
and resuming on reconnect is cheap enough to do exactly when your accounting
says to, rather than a few minutes later.
An E2B sandbox that was left with onTimeout: "kill" is gone at its deadline.
On Freestyle nothing is deleted implicitly, so delete a workspace explicitly
when its lifecycle ends, or set autoDeleteSeconds for a bounded cleanup.
Run Commands
commands.run() in the foreground becomes vm.exec(). The options rename:
envs is env, user is linuxUser, and cwd is a cd inside the command
string, which the guest runs through its shell. Two behaviors differ:
- Exit status is a value, not an exception. E2B throws
CommandExitErroron a non-zero exit. Freestyle returns{ stdout, stderr, statusCode }and leaves the check to you.statusCodeisnullwhen the timeout killed the command. - The timeout is capped at five minutes.
timeoutMsaccepts up to 300000. Anything that can run longer needs the detached pattern below.
// commands.run("npm test", { cwd: "/project/workspace", envs: { CI: "1" }, user: "node", timeoutMs: 120_000 })
const result = await vm.exec({
command: "cd /project/workspace && npm test",
env: { CI: "1" },
linuxUser: "node",
timeoutMs: 120_000,
});
if (result.statusCode !== 0) {
throw new Error(`tests failed: ${result.stderr}`);
}
exec starts in the user’s home directory and runs as the VM’s default user,
the account holding uid 1000, unless linuxUser names another. On the Ubuntu
base snapshots that is ubuntu, with passwordless sudo; E2B’s default was
user. If your image relies on a node account, create it in the snapshot
and name it on every call, or use vm.linuxUser("node") once and call exec
and pty on that handle.
stdin is a base64 string of up to 1 MiB and is delivered whole. There is no
sendStdin or closeStdin; a process that needs interactive input gets a PTY.
Stream And Detach Long Commands
commands.run(cmd, { background: true, onStdout }) has two jobs: it streams
output as it arrives, and it keeps a process alive past the request. Freestyle
splits those across two tools.
A PTY streams. vm.pty.open({ exec }) runs a command in a terminal,
delivers output through onData as it is produced, reports the exit code on
onExit, and has no duration limit. Name the session with slug so any
process, including one that starts after yours crashes, can attach to it
by name, which is what commands.connect(pid) did:
const build = await vm.pty.open({
exec: "cd /project/workspace && npm run build",
slug: "build-42",
onData: (bytes) => process.stdout.write(bytes),
onExit: (code) => console.log("build exited", code),
});
// Later, from anywhere:
const attached = await vm.pty.attach({
session: "build-42",
onData: (bytes) => process.stdout.write(bytes),
onExit: (code) => console.log("build exited", code),
});
attached.signal("sigint"); // commands.kill(pid), gently
await vm.pty.close("build-42"); // commands.kill(pid), for real
A PTY merges stdout and stderr and applies terminal line discipline, so treat
its bytes as a terminal transcript rather than as two clean pipes. open()
with a slug is get-or-create: if a session with that name exists you get it
back and exec does not run, so check session.created when you meant to
start something new.
A detached process keeps clean streams. When you need separate stdout and
stderr, an exit status, and no terminal in the way, start the job with
nohup, redirect its output to files, and record the exit status when it
finishes. exec returns as soon as the shell does:
const job = "job-42";
await vm.exec({
command:
`mkdir -p /var/tmp/jobs/${job} && cd /project/workspace && ` +
`nohup bash -c 'npm run build; echo $? > /var/tmp/jobs/${job}/exit' ` +
`> /var/tmp/jobs/${job}/stdout 2> /var/tmp/jobs/${job}/stderr < /dev/null &`,
});
Poll for the exit file and read the logs with ranged reads, which is how you tail without re-downloading:
const stdoutPath = `/var/tmp/jobs/${job}/stdout`;
let offset = 0;
async function drain() {
const { size } = await vm.fs.stat(stdoutPath);
if (size <= offset) return;
const chunk = await vm.fs.readTextFile(stdoutPath, { offset, length: size - offset });
offset = size;
process.stdout.write(chunk);
}
while (!(await vm.fs.exists(`/var/tmp/jobs/${job}/exit`))) {
await drain();
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await drain();
const exitCode = Number((await vm.fs.readTextFile(`/var/tmp/jobs/${job}/exit`)).trim());
To cancel, vm.exec("pkill -f 'npm run build'") or record $! to a pid file
in the same launcher. To find running jobs, vm.exec("ps -eo pid,args") takes
the place of commands.list().
For a service that should always be running, such as a dev server, neither pattern is right. Run it under systemd and snapshot the VM, as Rebuild Templates As Snapshots shows.
Move Files
sandbox.files becomes vm.fs. Reads and writes carry raw bytes with no
size limit short of 16 GiB per file, and a write lands atomically after its
hash is verified in the guest.
// files.read(path) / files.read(path, { format: "bytes" })
const text = await vm.fs.readTextFile("/project/workspace/package.json");
const bytes = await vm.fs.readFile("/project/workspace/logo.png");
// files.write(path, data)
await vm.fs.writeTextFile("/project/workspace/.env", "PORT=3000\n");
await vm.fs.writeFile("/project/workspace/data.bin", new Uint8Array(buffer), { mode: 0o644 });
// files.list(path), files.getInfo(path), files.exists(path)
const entries = await vm.fs.readDir("/project/workspace"); // [{ name, kind }]
const info = await vm.fs.stat("/project/workspace/package.json"); // size, owner, modified, isSymlink
const there = await vm.fs.exists("/project/workspace/dist");
// files.makeDir(path), files.remove(path)
await vm.fs.mkdir("/project/workspace/tmp");
await vm.fs.remove("/project/workspace/tmp");
readDir is one level deep and returns name and kind only; call stat
for sizes and modification times, or vm.exec("find ... -printf") for a whole
tree with metadata in one round trip, which replaces list(path, { depth }).
rename is vm.exec("mv old new"). Batch writes are parallel single writes:
await Promise.all(files.map(({ path, data }) => vm.fs.writeFile(path, data)));
Uploads. writeFile takes a string, Uint8Array, or Blob. A Blob is
streamed a chunk at a time, and above 8 MiB the SDK switches to resumable
chunks on its own, so a local file goes in as await openAsBlob(path) with no
size planning. It does not take a ReadableStream of unknown length; spool
such a stream to a temporary file first, or build a tar archive and send that.
For a directory, the CLI’s freestyle vm scp ./dir <vm>:/path copies
recursively in either direction.
Downloads. readFile buffers; readFileStream returns a web
ReadableStream to pipe to disk or to an HTTP response. Reads are ranged
through offset and length, so a Range request from a browser can be
served with an exact slice.
Signed URLs. There is no uploadUrl() or downloadUrl(), and the file
API is available only to your API key, not to
identity tokens. Proxy file traffic through
your server, which keeps the key there and lets you authorize each request,
streaming with readFileStream and Blob uploads so the server never holds
a whole file.
Ownership. File operations run as root, so a file you write is root-owned
unless the path already existed. If the application user must write it,
chown after the upload or mkdir the tree as that user once in the
snapshot; mode sets the permission bits on each write.
Watch A Directory
There is no watchDir() in the API. Run a watcher inside the guest and stream
its output over a PTY. Install inotify-tools while building your snapshot:
await builder.exec("sudo apt-get install -y inotify-tools");
Then, per workspace, open one long-lived session and parse its lines. This
covers recursive: true and the create, delete, modify, and move events E2B
reported; chmod is the ATTRIB event:
const watcher = await vm.pty.open({
exec:
"inotifywait -m -r -q --format '%e\t%w%f' " +
"--exclude '(node_modules|\\.git|\\.next|dist)/' " +
"-e create,delete,modify,moved_from,moved_to,attrib /project/workspace",
slug: "watch-workspace",
onData: (bytes) => {
for (const line of new TextDecoder().decode(bytes).split("\n")) {
const [events, path] = line.trimEnd().split("\t");
if (path) console.log(events, path); // e.g. "MODIFY /project/workspace/src/app.ts"
}
},
});
Because the session is named, a client that reconnects attaches to the same
watcher instead of starting a second one, and vm.pty.close("watch-workspace")
stops it. A line can be split across two onData calls; buffer to the newline
if you parse strictly. Watcher output is terminal output, so it does not
count as activity for an idle timeout.
If your file explorer only needs to catch up on reconnect rather than react
live, vm.exec("find /project/workspace -newer /tmp/last-sync -type f") is
cheaper than a permanent watcher.
Terminals
sandbox.pty maps to vm.pty with the operations renamed and the process ID
replaced by a session that outlives your connection:
// pty.create({ cols, rows, onData, user })
const session = await vm.linuxUser("node").pty.open({
cols: 120,
rows: 30,
slug: "main",
replaceOnExit: true, // respawn the shell in place when it exits
onData: (bytes) => socket.send(bytes),
onExit: (code) => console.log("shell exited", code),
});
session.write("ls -la\n"); // pty.sendInput(pid, data)
session.resize({ cols: 200, rows: 60 }); // pty.resize(pid, size)
session.signal("sigint"); // Ctrl-C
session.detach(); // close the socket; the shell keeps running
const again = await vm.linuxUser("node").pty.attach({ session: "main", onData }); // pty.connect(pid)
await vm.linuxUser("node").pty.close("main"); // pty.kill(pid)
Sessions persist across client disconnects and across pause and resume, so a
tmux layer you added on E2B for continuity is no longer necessary, though it
keeps working if you keep it. Pausing freezes PTY processes with the VM;
start the VM before attaching again. The SDK does not reconnect a dropped
socket on its own; call attach() with the saved session again.
The PTY WebSocket carries authorization headers, which browsers cannot set, so open it from a server or a desktop process and relay bytes to the browser. Run a Web Terminal in a Sandbox has the relay.
Rebuild Templates As Snapshots
An E2B template is a build recipe; a Freestyle snapshot is a captured machine. Translate the recipe into setup commands, run them once on a VM booted from a base snapshot, and snapshot the result. Every VM created from that snapshot starts with the same disk and the same memory, including processes that were running at capture.
Take a typical E2B template:
const template = Template()
.fromImage("node:22")
.setUser("root")
.runCmd("apt-get update && apt-get install -y git tmux sqlite3")
.runCmd("useradd -m -s /bin/bash node || true")
.copy("scaffold/", "/project/workspace")
.runCmd("chown -R node:node /project && cd /project/workspace && npm install")
.setWorkdir("/project/workspace")
.setUser("node")
.setStartCmd("npm run dev -- -p 3000", waitForPort(3000));
await Template.build(template, "nextjs", { cpuCount: 2, memoryMB: 4096 });
Its Freestyle counterpart runs on the builder VM. The base snapshot decides
the hardware, so pick the size in
place of cpuCount and memoryMB; freestyle/ubuntu is 4 vCPU, 8 GiB, and
already has Node.js LTS, Git, Docker, and Python. The start command becomes a
systemd unit, and the ready command becomes a poll before the snapshot:
import { openAsBlob } from "node:fs";
const { vm: builder } = await freestyle.vms.create({
snapshotId: "freestyle/ubuntu",
slug: "nextjs-builder",
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
});
const root = builder.linuxUser("root");
// runCmd(...) as root
await root.exec("apt-get update && apt-get install -y tmux sqlite3 inotify-tools");
await root.exec("id node >/dev/null 2>&1 || useradd -m -s /bin/bash node");
// copy("scaffold/", "/project/workspace") — pack it locally, unpack in the VM
await builder.fs.writeFile("/tmp/scaffold.tar", await openAsBlob("scaffold.tar"));
await root.exec("mkdir -p /project/workspace && tar -xf /tmp/scaffold.tar -C /project/workspace");
await root.exec("chown -R node:node /project");
await builder.linuxUser("node").exec({ command: "cd /project/workspace && npm install", timeoutMs: 300_000 });
// setStartCmd("npm run dev -- -p 3000", waitForPort(3000))
await builder.fs.writeTextFile(
"/etc/systemd/system/app.service",
`[Service]
User=node
WorkingDirectory=/project/workspace
ExecStart=/usr/bin/bash -lc 'exec npm run dev -- -H 0.0.0.0 -p 3000'
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await root.exec("systemctl daemon-reload && systemctl enable --now app");
let ready = false;
for (let attempt = 0; attempt < 60 && !ready; attempt++) {
const probe = await builder.exec("curl -sf -o /dev/null http://127.0.0.1:3000 && echo ok");
ready = probe.stdout?.trim() === "ok";
if (!ready) await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("dev server never became ready");
// Template.build(template, "nextjs")
const { snapshotId } = await builder.snapshot({ slug: "nextjs" });
await builder.delete();
snapshot() returns once the snapshot is materialized and ready to boot, and
a VM created from it comes up with the dev server already listening. The
Restart=always unit also covers E2B’s behavior of relaunching the start
command; on Freestyle the process is simply still there, and systemd restarts
it if it ever exits.
A few translations to keep in mind:
fromDockerfile. Either translate theRUNlines intoexeccalls as above, or keep the image and run it as a container inside the VM with Docker, which is already installed. The second is faster to migrate and the first gives the application the whole machine.setEnvsandenvs. Build-time variables areenvon theexecthat needs them. Variables every process should see go in/etc/environmentor the systemd unit’sEnvironment=lines before the snapshot.- Names and tags. A snapshot has one slug. Publish a new version by
snapshotting again and moving the slug with
freestyle.vms.snapshots.update(newId, { slug: "nextjs" }), keeping the old snapshot addressable by ID for rollback. Snapshot slugs are per account. - Build logs. Each
execreturns its output; printstdoutandstderras you go whereonBuildLogsused to run. - Scripted builds. The CLI does the same thing in one command:
freestyle snapshot create --base freestyle/ubuntu --script ./setup.sh --slug nextjsboots a VM, streams the script, captures the VM when it exits zero, and takes no snapshot if it fails. See Build A Snapshot From Scratch.
The Next.js and Vite guides carry complete builder scripts for those stacks.
Publish Previews
sandbox.getHost(port) returns a hostname that exists for every port. On
Freestyle you declare which ports are public: one TLS rule routes one
hostname to one VM port. Any unused subdomain of style.dev is free and needs
no DNS, or use a domain you have verified.
// const url = `https://${sandbox.getHost(3000)}`
const domain = `ws-${workspaceId}.style.dev`;
await freestyle.tls.rules.create({
action: "allow",
domain,
source: { public: true },
destination: { vmId, port: 3000 },
});
const url = `https://${domain}`;
Declare the rule with the VM instead when the port is known at creation. An endpoint with no identity means the VM being created:
const { vm, vmId } = await freestyle.vms.create({
snapshotId: "nextjs",
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
tls: {
rules: [{ action: "allow", domain, source: { public: true }, destination: { port: 3000 } }],
},
});
The edge terminates HTTPS and forwards the connection to the guest port, so
the dev server must listen on 0.0.0.0. Rules are deleted with the VM. A
preview on a paused VM wakes it, which is how autoResume treated preview
traffic; give a workspace an idle timeout if you want it paused again after
the visit.
Private previews. allowPublicTraffic: false plus the
e2b-traffic-access-token header becomes forward auth.
Freestyle calls your authorization endpoint for each request with the
caller’s Authorization and Cookie headers and forwards only on a 2xx,
so the check runs in your existing session system rather than as a shared
token the browser must carry:
const auth = await freestyle.tls.forwardAuth.create({
url: "https://api.acme.com/previews/authorize",
headers: { authorization: `Bearer ${process.env.PREVIEW_AUTH_SECRET}` },
timeoutMs: 1500,
protectedCookies: ["__Host-acme-session"], // seen by your authorizer, never by the VM
});
await freestyle.tls.rules.create({
action: "allow",
domain,
source: { public: true },
destination: { vmId, port: 3000 },
forwardAuth: { id: auth.id },
});
Your authorizer reads X-Freestyle-TLS-Rule-Id to learn which preview is
being requested and returns 2xx to allow, 3xx to send a signed-out user to
login, or 4xx to refuse. WebSocket upgrades pass through the same rule, so
hot module reloading works behind it. A cookie-based sign-in handoff for many
previews on one authorizer is in
Protect A Sandbox With Forward Auth.
If you proxy previews through your own server today, injecting the E2B token
upstream, keep the proxy and drop the token: with forward auth the edge asks
your server instead of the other way around. A proxy that must stay in the
path can instead use a protectedCookies or headers value it alone knows.
Hand Clients Scoped Access
E2B lets a backend hand its desktop or CLI clients the sandbox ID, the envd
access token, and the traffic token so they can call the sandbox directly
without the team API key. Freestyle’s equivalent is an identity: your server
grants it one or more VMs, restricted to named Linux users, and mints a token
the client uses as its own credential.
const { identity, identityId } = await freestyle.identities.create();
await identity.permissions.vm.grant({ vmId, allowedLinuxUsers: ["node"] });
const { id: tokenId, token } = await identity.tokens.create();
return { vmId, token }; // to the desktop app or CLI
import { Freestyle } from "freestyle";
const freestyle = new Freestyle({ identityAccessToken: token });
const workspace = freestyle.vms.ref(vmId).linuxUser("node");
await workspace.exec("git status");
const shell = await workspace.pty.open({ onData: render });
The grant carries allowedLinuxUsers, so a client holding it cannot run
anything as root, and a call that names no user is refused. It also cannot
touch VMs it was not granted or create account resources. Revoke a client
with identity.tokens.revoke(tokenId) or identity.permissions.vm.revoke(vmId)
without rotating anything else, and delete the identity when the user leaves.
SSH accepts the same token, so ssh <vm>+node:<token>@beta-ssh.freestyle.sh
gives a CLI a terminal with no code at all.
An identity token reaches only the exec and PTY routes. It cannot call
vm.fs, which runs as root in the guest, so an E2B client that read and
wrote files directly with its scoped session needs one of two changes. Route
file traffic through your server, which holds the API key and can check the
user’s permission on each request. Or move small transfers onto exec as the
granted user, with stdin for uploads of up to 1 MiB and stdout for
downloads, which keeps every byte under that user’s own permissions.
Control Egress
An E2B sandbox has Internet access unless allowInternetAccess: false, and
allowOut and denyOut refine that. A Freestyle VM has none until a
firewall rule allows it, and only allow rules exist.
| E2B | Freestyle |
|---|---|
allowInternetAccess: true (default) | firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] } |
allowInternetAccess: false | firewall: { rules: [] } |
allowOut: ["1.1.1.1", "8.8.8.0/24"] with denyOut: allTraffic | One rule per range: destination: { cidr: "8.8.8.0/24" } |
allowOut: ["api.github.com"] | A TLS rule per exact domain, destination: { public: true } |
allowOut: ["*.example.com"] | Not available yet; only exact names steer |
denyOut: [...] alongside broad allow | Not available; start from nothing and allow what is needed |
| Secrets injected into requests | A transform on the TLS rule |
A TLS egress rule does two things at once: it admits the VM to that domain with no broader Internet grant, and it can inject a header the guest never held. That is how you replace E2B secrets with a key that lives at the edge:
await freestyle.tls.rules.create({
action: "allow",
domain: "api.openai.com",
source: { vmId },
destination: { public: true },
transform: [{ headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY}` } }],
});
The OpenAI, Anthropic, and private Git guides cover the client-side certificate trust each stack needs.
Account For What Has No Equivalent
- Streaming
exec.vm.exec()buffers. Use a PTY or the detached pattern above; do not expectonStdouton a request-response call. - Sandbox-wide
envs. Nothing sets environment for every process at create time. Passenvperexec, or set it in the snapshot. fork({ count }). Snapshot once, then create as many VMs as you need from the snapshot; the snapshot is reusable and the VMs are independent.- Filesystem-only pause and reboot-on-resume. A Freestyle pause always
keeps memory. For a fresh boot, power the VM off from inside the guest and
start()it; a stopped persistent VM keeps its disk. See Stopped. - Lifecycle webhooks and the events API. Not offered. Poll
vm.data()orvms.list({ state }), or emit events from the code that pauses and deletes, since on Freestyle that is your code. - Metrics.
data().cpuTimeSecondsandlastNetworkActivityare the per-VM figures; read memory and disk usage from inside the guest. - Volumes and cloud buckets. Mount object storage from inside the VM with the tool of your choice, or share data between VMs over a VPC.
gitmodule. Rungitwithexec; credentials for private remotes are injected at the edge by the Git guide.- MCP gateway and Code Interpreter. Run the servers or a Jupyter kernel in the VM and route a domain to them.
- Client file access. Identity tokens cover exec and PTY only; see Hand Clients Scoped Access for the two ways to move files on a client’s behalf.
Verify Before Cutting Over
Run these against a real VM before moving traffic, in this order, because the later checks depend on the earlier ones:
- Image. Boot from your snapshot, confirm the application user, its
packages, and the service are as expected, and that
execas that user can write to the workspace. Run your existing template verification against it. - Commands. Run a job longer than five minutes through a named PTY or the detached pattern. Verify separate streams where you need them, the exit code, cancellation, and that a second process can attach after the first disconnects.
- Files. Exercise a large upload, a ranged download, a batch write, and the watcher; confirm ownership of written files matches what the application expects.
- Lifecycle. Leave a VM idle past its
idleTimeoutSeconds, confirm it pauses, then wake it three ways: anexec, a PTY keystroke, and a preview request. Confirm a CPU-only job on a VM with no idle timeout is still running an hour later. Pause and resume with a PTY open and reattach. - Previews. Load the preview, confirm an unauthorized request is refused at the edge, and confirm a WebSocket upgrade and hot reload work through forward auth, before and after a pause.
- Client access. From a client holding only an identity token, run a command and open a terminal as the granted user; confirm root is refused, another VM is refused, a file call is refused, and a revoked token stops working.
When those pass, point new workspaces at Freestyle and keep E2B available
until existing ones have been recreated or drained. Delete each retired
sandbox on E2B with kill(), since paused sandboxes there are kept until you
do.