# How to Run JupyterLab on an HTTPS URL in a Cloud Sandbox

> Install JupyterLab with uv in a venv, run it as an enabled systemd service on 0.0.0.0:8888 with its own token turned off, and host the port with sbx host. The private hosted URL's token then guards the Lab page, the REST API and the kernel websocket. Notebooks and running kernels survived sbx stop --wait and sbx resume.

Source: https://sandboxer.com/guides/run-jupyterlab-on-an-https-url. Updated 2026-10-27.

**Short answer:** Install JupyterLab with uv in a venv, run it as an enabled systemd service on 0.0.0.0:8888 with its own token turned off, and host the port with sbx host. The private hosted URL's token then guards the Lab page, the REST API and the kernel websocket. Notebooks and running kernels survived sbx stop --wait and sbx resume.

This guide is about the JupyterLab web UI, for people. If you want a headless kernel that an LLM app sends code to, see [Build a stateful code interpreter with a Jupyter kernel](/guides/build-a-stateful-code-interpreter-with-jupyter).

## What we ran

Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB): JupyterLab 4.6.4, Python 3.12.3, uv 0.13.1. Timings are single runs.

| Step | Result |
| - | - |
| Create the venv and install JupyterLab with uv | Under 5 s |
| Hosted URL without its token | `401`, for `/lab` and `/api/status` |
| Hosted URL with its token | `302` that sets a cookie, then `/lab` returns `200` |
| Start a kernel over REST and run a cell over the websocket | 0.96 s, from our laptop |
| `%pip install` from a notebook kernel | Works if the venv was made with `--seed` |
| `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.4 s. Memory restored: same kernel, same PID, variables kept |
| Resume that boots from disk | systemd restarted JupyterLab; `/api/status` answered on the first poll. Notebooks kept, kernels gone |

A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped.

## 1. Install JupyterLab with uv

The base image has Python 3.12 and [uv](https://docs.astral.sh/uv/pip/environments/). JupyterLab isn't preinstalled. Put it in its own venv:

```bash
sbx new --name jupyterlab --type small
id=sb_dw3osvm3vw    # the id that sbx new printed

sbx exec $id --timeout 600 "uv venv --seed ~/jlab --python 3.12 -q && \
  VIRTUAL_ENV=~/jlab uv pip install -q jupyterlab && \
  ~/jlab/bin/jupyter lab --version && ~/jlab/bin/pip --version"
```

```text
4.6.4
pip 26.2.2 from /home/user/jlab/lib/python3.12/site-packages/pip (python 3.12)
```

`--seed` puts pip into the venv. A uv venv has no pip by default, and the notebook's `%pip` magic needs it. Without `--seed`, this is what a notebook cell printed:

```text
/home/user/jlab/bin/python: No module named pip
```

`sbx exec` stops a command after 30 seconds by default. `--timeout 600` gives a slow install room.

## 2. Run it as a systemd service

A process you start by hand is lost when a sandbox boots from disk. An enabled systemd service starts again on its own ([long-running docs](/docs/long-running#services-that-should-always-run)).

```bash
sbx exec $id "mkdir -p ~/notebooks && sudo tee /etc/systemd/system/jupyterlab.service >/dev/null <<EOF
[Unit]
Description=JupyterLab
After=network-online.target

[Service]
User=user
WorkingDirectory=/home/user/notebooks
ExecStart=/home/user/jlab/bin/jupyter lab --no-browser --ip=0.0.0.0 --port=8888 --IdentityProvider.token=
Restart=always

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now jupyterlab && sleep 4 && \
systemctl is-active jupyterlab && curl -s -o /dev/null -w '%{http_code}\n' localhost:8888/api/status"
```

```text
active
200
```

- `--ip=0.0.0.0` lets the hosting proxy reach JupyterLab on the sandbox's address.
- `--IdentityProvider.token=` (empty) turns off JupyterLab's own login token. The hosted URL's token does that job in step 3. The option is described in the [Jupyter Server security docs](https://jupyter-server.readthedocs.io/en/latest/operators/security.html).
- `~/notebooks` is the folder the file browser opens. Your notebooks live on the sandbox disk.

## 3. Host the port on a private URL

```bash
sbx host $id 8888 --title JupyterLab
```

```text
https://rosy-zany-sequoia-8888.box.sandboxer.com?_token=<token>
```

A hosted port is private by default ([Hosting](/docs/hosting#private-and-public)). Without the token, the proxy answers and JupyterLab never sees the request:

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://rosy-zany-sequoia-8888.box.sandboxer.com/lab
curl -s -o /dev/null -w "%{http_code}\n" https://rosy-zany-sequoia-8888.box.sandboxer.com/api/status
```

```text
401
401
```

With the token, the first response sets a 30-day cookie and redirects. The root path then sends you on to Lab:

```text
HTTP/2 302
location: /
set-cookie: __sbx_auth=<cookie>; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax

HTTP/2 302
location: /lab?

<title>JupyterLab</title>
```

Open the full token URL in a browser and you get the Lab page. The browser keeps the cookie, so the file browser, the notebooks and the kernel websocket all work without extra steps.

With JupyterLab's token off, anyone with the full URL can run code in the sandbox. Treat the URL like a password. Never combine this setup with `sbx host --public`.

## 4. Start a kernel and run cells through the API

To check the kernel path without a browser, we drove the [Jupyter Server REST API](https://jupyter-server.readthedocs.io/en/latest/developers/rest-api.html) and the kernel websocket from Node 24. The script trades the hosted token for the cookie, loads `/lab` to get Jupyter's `_xsrf` cookie, starts a kernel, then sends each cell as an `execute_request` on the websocket ([message format](https://jupyter-client.readthedocs.io/en/latest/messaging.html#execute)):

```javascript
// node jlab-run.mjs "<private hosted URL with ?_token=…>" "<cell 1>" ["<cell 2>" …]
// Set KERNEL_ID to reuse a running kernel instead of starting one.
const start = new URL(process.argv[2]);
const cells = process.argv.slice(3);
const jar = new Map();
const keep = (res) => {
  for (const c of res.headers.getSetCookie()) {
    const [kv] = c.split(";");
    const i = kv.indexOf("=");
    jar.set(kv.slice(0, i), kv.slice(i + 1));
  }
};
const cookie = () => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
const base = `${start.protocol}//${start.host}`;

// 1. Trade the hosted-URL token for the __sbx_auth cookie.
keep(await fetch(start, { redirect: "manual" }));
// 2. Load the Lab page; Jupyter sets its _xsrf cookie.
const lab = await fetch(`${base}/lab`, { headers: { Cookie: cookie() } });
keep(lab);
console.log("GET /lab", lab.status);
// 3. Start a kernel through the REST API (or reuse one).
const t0 = performance.now();
let kernelId = process.env.KERNEL_ID;
if (!kernelId) {
  const r = await fetch(`${base}/api/kernels`, {
    method: "POST",
    headers: { Cookie: cookie(), "X-XSRFToken": jar.get("_xsrf"), "Content-Type": "application/json" },
    body: JSON.stringify({ name: "python3" }),
  });
  const kernel = await r.json();
  console.log("POST /api/kernels", r.status, kernel.id ?? kernel);
  kernelId = kernel.id;
}
// 4. Open the kernel websocket and run the cells in order.
const ws = new WebSocket(`wss://${start.host}/api/kernels/${kernelId}/channels`, { headers: { Cookie: cookie() } });
const session = crypto.randomUUID();
let msgId;
const run = () => {
  const code = cells.shift();
  if (code === undefined) { console.log(`done in ${Math.round(performance.now() - t0)} ms`); process.exit(0); }
  msgId = crypto.randomUUID();
  ws.send(JSON.stringify({
    header: { msg_id: msgId, msg_type: "execute_request", username: "", session, version: "5.3", date: new Date().toISOString() },
    parent_header: {}, metadata: {}, channel: "shell",
    content: { code, silent: false, store_history: true, user_expressions: {}, allow_stdin: false, stop_on_error: true },
  }));
};
ws.onopen = run;
ws.onerror = (e) => { console.log("websocket error", e.message ?? e); process.exit(1); };
ws.onmessage = (e) => {
  const m = JSON.parse(e.data);
  if (m.parent_header?.msg_id !== msgId) return;
  const t = m.header.msg_type;
  if (t === "stream") process.stdout.write(m.content.text);
  if (t === "execute_result") console.log(m.content.data["text/plain"]);
  if (t === "error") console.log(m.content.ename, m.content.evalue);
  if (t === "execute_reply") { console.log(`[${m.content.execution_count}] ${m.content.status}`); run(); }
};
```

This run starts a kernel, installs a package from inside it with `%pip`, and uses it:

```bash
node jlab-run.mjs "$URL" "%pip install -q humanize" \
  "import humanize; humanize.naturalsize(3_500_000_000)" "import sys; sys.executable"
```

```text
GET /lab 200
POST /api/kernels 201 881945a3-7fe0-4232-896a-447045e2a2bf
Note: you may need to restart the kernel to use updated packages.
[1] ok
'3.5 GB'
[2] ok
'/home/user/jlab/bin/python'
[3] ok
done in 2899 ms
```

The package went into `~/jlab`, the venv the kernel runs in. A single cell with no install (start the kernel, run it, get the reply) took 0.96 s end to end.

What the proxy and Jupyter checked along the way:

| Request | Result |
| - | - |
| Any path, no hosted token or cookie | `401` from the proxy |
| `POST /api/kernels` with the cookie but no `X-XSRFToken` header | `403` from Jupyter |
| `POST /api/kernels` with the cookie and `X-XSRFToken` | `201` |
| `wss://…/api/kernels/<id>/channels` with the cookie | Connected, cells ran |

The websocket needs the `__sbx_auth` cookie. Passing `?_token=` on the websocket URL gets a redirect, which websocket clients treat as a failed handshake ([details](/guides/give-a-sandbox-port-an-https-url)). Browsers already send the cookie.

## 5. Or keep JupyterLab's own token

If you would rather let JupyterLab check logins, set its token and leave its auth on. We tested this on a public hosted port, so only Jupyter's token stood in the way:

```bash
# /etc/systemd/system/jupyterlab.service.d/token.conf
[Service]
ExecStart=
ExecStart=/home/user/jlab/bin/jupyter lab --no-browser --ip=0.0.0.0 --port=8888
Environment=JUPYTER_TOKEN=<a long random string>
```

```text
/lab, no token                     302 -> /login?next=%2Flab
/api/kernels, no token             403
/api/kernels, Authorization: token 200
kernel websocket with ?token=…     connected, cell printed "hello over wss"
```

Both setups work through the proxy. With a private hosted port and Jupyter's token off (steps 2 and 3), you keep one secret, and the proxy rejects bad requests before they reach Jupyter. A public port with Jupyter's token puts the JupyterLab login page on the open internet. Switching a port between public and private also mints a new hosted token, so old private links stop working.

## 6. What survives stop and resume

We saved a notebook through the contents API and set `x = 42` in a running kernel (PID 918). Then:

```bash
sbx stop $id --wait
sbx resume $id
```

The stop took 2.2 s and the resume 1.4 s, with `memoryRestored: true`. The browser cookie from before the stop still worked, and the same kernel answered:

```text
old cookie /lab: 200
['demo.ipynb']
1 kernels
(42, 918, '1.0 GB')
```

That's the variable, the same PID and the `humanize` import, all from before the stop.

A resume boots from disk instead when no machine with the same CPU type is free, or when you change the size. We forced it with `sbx resume $id --type default` (`memoryRestored: false`, `resumeReason: "resized"`). systemd started JupyterLab again, and `/api/status` answered `200` on our first poll after the resume returned. The notebook file and the installed package were still there. The kernel was gone:

```text
['demo.ipynb']
[]
POST /api/kernels 201 f6bebce0-35f2-4b94-919d-bc437b4259c4
'1.0 GB'
[1] ok
NameError name 'x' is not defined
[2] error
```

Save your notebooks and rerun them after a boot from disk. Packages you installed into the venv stay. Moving to a smaller size isn't supported yet; to shrink, create a new sandbox and copy `~/notebooks` across. See [snapshots](/docs/snapshots#resume) for the resume rules.

## 7. Clean up

```bash
sbx delete $id --yes
```

## FAQ

### Do I need JupyterLab's token if the hosted URL is private?

No. In our test, every request we sent without the hosted token got `401` from the proxy, so it never reached JupyterLab. Turn Jupyter's token off only when the port is private, and keep the full URL secret.

### Why does %pip say "No module named pip"?

uv makes venvs without pip unless you pass `--seed`. Create the venv with `uv venv --seed`, as in step 1, and `%pip install` installs into the venv the kernel runs in.

### Do my notebooks survive when the sandbox stops?

Yes. Notebooks are files on the sandbox disk, and the disk is kept across stop and resume. With memory restored, running kernels and their variables come back too. After a boot from disk, the kernels are gone and you rerun your cells.

### Why do I get 403 when I call the Jupyter API with curl?

With Jupyter's token off, Jupyter checks its `_xsrf` cookie on POST requests. Load `/lab` once to get the cookie, then send its value in an `X-XSRFToken` header, as the script in step 4 does.

### Can several people use the same JupyterLab?

Anyone with the full private URL can open it. They share one Linux user and one set of kernels. For separate workspaces, give each person their own sandbox.

## Related

- [Build a stateful code interpreter with a Jupyter kernel](/guides/build-a-stateful-code-interpreter-with-jupyter)
- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory)
- [Hosting](/docs/hosting)
