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@latestbun add freestyle@latestnpm install freestyle@latestyarn add freestyle@latest 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 freestyle = new Freestyle();
const { vm: builder } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "jupyter-builder" });
// 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. Any unused subdomain of style.dev is yours to take, with no verification and no DNS records; to use a name you own, verify it and point its DNS at Freestyle first.
const { vm, vmId } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "jupyter-server", snapshotId, idleTimeoutSeconds: null });
const domain = "my-notebook.style.dev"; // any unused style.dev subdomain
await freestyle.tls.rules.create({
action: "allow",
domain,
source: { public: true },
destination: { vmId, port: 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.