Freestyle Docs

Freestyle / Guides

How to Run a Jupyter Notebook Server in a Sandbox

Bake JupyterLab into a VM snapshot, boot it as a systemd service, and open the notebook on a public domain.

Build a snapshot with JupyterLab already running, then boot a VM that comes up serving notebooks and route a public domain to it.

Install the SDK

pnpm add freestyle
bun add freestyle
npm install freestyle
yarn add freestyle

Set your API key before calling the API:

export FREESTYLE_API_KEY="your-api-key"

Build a Snapshot with JupyterLab Running

Install JupyterLab into a virtual environment, run it as a systemd service bound to 0.0.0.0, and wait until it serves before snapshotting. allow_remote_access and allow_origin let Jupyter answer through a domain. An empty --IdentityProvider.token= starts the server open, with no access token — see Add a Token to gate it behind one. A Freestyle snapshot captures the running process, so VMs booted from it come up already serving.

import { freestyle } from "freestyle";

const { vm: builder } = await freestyle.vms.create();

// The base image is minimal, so install JupyterLab in a virtual environment.
await builder.exec(
  "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-venv",
);
await builder.exec("python3 -m venv /opt/venv");
await builder.exec("/opt/venv/bin/pip install --quiet jupyterlab");

// Run JupyterLab as a systemd unit. systemd is PID 1, so it supervises the server.
await builder.fs.writeTextFile(
  "/etc/systemd/system/jupyter.service",
  `[Service]
Environment=HOME=/root
ExecStart=/opt/venv/bin/jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.allow_remote_access=True --ServerApp.allow_origin=* --IdentityProvider.token=
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now jupyter");

// Wait until the server is listening (any HTTP response, not "000"), then snapshot.
let code = "000";
while (code === "000") {
  await new Promise((r) => setTimeout(r, 1500));
  code = (
    await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:8888/")
  ).stdout.trim();
}

const { snapshotId } = await builder.snapshot();
await builder.delete();

Open the Notebook on a Domain

Create a VM from the snapshot — JupyterLab is already running — then route a domain to port 8888. Pick your own unique *.style.dev subdomain; it needs no DNS or verification.

const { vm, vmId } = await freestyle.vms.create({ snapshotId, idleTimeoutSeconds: null });

const domain = `my-notebook-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8888 });

console.log(`https://${domain}/lab`);

Open that URL in a browser and JupyterLab loads, ready to run notebooks. Anyone with the link gets straight in; add a token for anything you do not want public.

Stream the Server’s Logs

vm.exec() buffers a command and only returns once it finishes, so it can’t show a long-running service’s output as it happens. To watch the logs live, open a PTY on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit’s journal. onData delivers the bytes as they arrive:

const session = await vm.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // live log lines
});

// Follow the service; new lines stream in until you detach.
session.write("journalctl -u jupyter -f\n");

// session.detach() drops your handle — the service keeps running in the VM.

Add a Token

The build above runs JupyterLab open, so the domain drops visitors straight into notebooks. To require an access token instead, drop the empty --IdentityProvider.token= flag and set a JUPYTER_TOKEN in the unit. Make this change in the build step and re-snapshot — the token is baked into the snapshot, so every VM booted from it comes up gated.

await builder.fs.writeTextFile(
  "/etc/systemd/system/jupyter.service",
  `[Service]
Environment=HOME=/root
Environment=JUPYTER_TOKEN=change-me-to-a-secret
ExecStart=/opt/venv/bin/jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.allow_remote_access=True --ServerApp.allow_origin=*
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target
`,
);

Now the notebook requires the token in the URL — open it as https://${domain}/lab?token=change-me-to-a-secret. Use a long, random value — crypto.randomUUID() works — for anything you do not want public. The readiness loop in the build step is unchanged: it waits for the server to start listening, which happens with or without a token.

esc