Skip to content
Guides · Recipes

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

Updated October 27, 2026 .md

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.

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. JupyterLab isn't preinstalled. Put it in its own venv:

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"
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:

/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).

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"
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.
  • ~/notebooks is the folder the file browser opens. Your notebooks live on the sandbox disk.

3. Host the port on a private URL#

sbx host $id 8888 --title JupyterLab
https://rosy-zany-sequoia-8888.box.sandboxer.com?_token=<token>

A hosted port is private by default (Hosting). Without the token, the proxy answers and JupyterLab never sees the request:

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
401
401

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

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 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):

// 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:

node jlab-run.mjs "$URL" "%pip install -q humanize" \
  "import humanize; humanize.naturalsize(3_500_000_000)" "import sys; sys.executable"
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). 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:

# /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>
/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:

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:

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:

['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 for the resume rules.

7. Clean up#

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.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide