---
title: "How to Run a Jupyter Notebook Server in a Sandbox"
description: "Bake JupyterLab into a VM snapshot, boot it as a systemd service, and open the notebook on a public domain."
url: "/docs/guides/run-jupyter-in-a-sandbox"
---

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

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
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](#add-a-token) to gate it behind one. A Freestyle snapshot captures the running process, so VMs booted from it come up already serving.

```ts {17}
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.

```ts
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](#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](https://www.freestyle.sh/docs/vms/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:

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

```ts {5-6}
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.
