all posts

Multi-Tenant Notebooks: microVM Sandboxes vs JupyterHub

Ajay Kumar··9 min read

If you're building a product that hands each user their own hosted notebook — a teaching platform, a data-science environment, an internal analytics tool, an AI "run this notebook" feature — you eventually hit the same fork in the road: JupyterHub, or a VM per user? It's tempting to read that as an either/or, but it isn't. JupyterHub is a genuinely excellent piece of software — a battle-tested auth, proxy, and user-management layer. The thing people actually mean when they ask the question is narrower: how isolated is one user's kernel from another's? And that answer has almost nothing to do with JupyterHub itself. It's entirely a property of the spawner you plug into it.

I'm Ajay — I built PandaStack, a Firecracker microVM platform, so I have a side here. But I want to be fair, because JupyterHub earned its reputation and the honest recommendation depends on your trust model. Let's walk the spawner isolation spectrum, then look at where a microVM-per-user actually changes the calculus — and where JupyterHub-on-containers is exactly the right call.

What JupyterHub actually is (and what it delegates)

JupyterHub is three things bolted together: an authenticator (who are you — OAuth, LDAP, PAM), a configurable HTTP proxy that routes each user to their notebook server, and a spawner that starts and stops those servers. The first two are the crown jewels. Multi-user auth, per-user routing, admin controls, culling of idle servers — this is hard, boring, security-sensitive work that JupyterHub has done well for a decade. You do not want to rebuild it.

The spawner is where the isolation decision lives, and JupyterHub deliberately makes it pluggable. LocalProcessSpawner runs each user's server as a Unix process on the Hub host. DockerSpawner runs each in a container. KubeSpawner runs each as a pod. Same Hub, same auth, same proxy — radically different blast radius. So "is JupyterHub secure for multi-tenant?" is unanswerable until you name the spawner.

Rule of thumb: JupyterHub owns the front door (auth + routing). The spawner owns the walls between rooms. When you compare against a microVM model, you're comparing spawners — not replacing JupyterHub's front door.

The spawner isolation spectrum

Rank the built-in and common spawners by how much stands between one user's code and the rest of the machine:

  • LocalProcessSpawner — every user is a process on the same host, sharing one kernel, one filesystem, and (mostly) one user namespace unless you carefully set per-user OS accounts. One user's notebook can read another's files, exhaust host memory, or exploit any local privilege-escalation bug. Fine for a single trusted team on a box they all already have shell on; running it for untrusted users is a trust fall where nobody's standing behind you.
  • DockerSpawner — each user gets a container: separate PID/mount/network namespaces and a cgroup memory/CPU cap. Real isolation for accidents and casual snooping, and the default for many self-hosted Hubs. But every container shares the host kernel, so a kernel vulnerability or container escape crosses tenants. Untrusted code plus a shared kernel is the exact threat model container escapes are about.
  • KubeSpawner — each user is a pod. Operationally excellent (scheduling, quotas, node autoscaling, network policies) and the standard for large deployments. But a pod is still containers: same shared-host-kernel boundary as Docker. Kubernetes adds a lot of policy machinery around that boundary; it does not change what the boundary is made of.
  • microVM-per-user (Firecracker) — each user's notebook runs in its own microVM with its own guest kernel, confined by hardware virtualization (KVM). There is no shared kernel to escape into; a break-out has to defeat the hypervisor, a far smaller and more heavily audited surface than the full Linux syscall interface a container sees. This is the isolation AWS Lambda and Fargate use for exactly this reason.

The pattern is a staircase: process < container < microVM < full VM. JupyterHub can stand on any of those steps. The higher you go, the stronger the tenant boundary and the more overhead per user — though, as we'll see, snapshot-restore has quietly collapsed most of that overhead.

The key move: run Jupyter inside the microVM, keep JupyterHub in front

Here's the insight that dissolves the false choice. A microVM is just a machine that boots Linux and runs whatever you put in it — including a full Jupyter server. So you don't replace JupyterHub; you write a spawner (or a thin wrapper) that spawns a microVM instead of a container, boots a notebook server inside it, and hands its address back to JupyterHub's proxy. Users still log in through the Hub, still get routed by the proxy, still get culled when idle. They just land in a hardware-isolated VM instead of a shared-kernel container.

On PandaStack, the microVM is a sandbox, and starting a notebook inside it is a couple of SDK calls. You provision a persistent sandbox on the code-interpreter or base template, install/launch a Jupyter server, and expose its port. Set PANDASTACK_API_KEY in your environment and the SDK picks it up:

from pandastack import Sandbox

# One microVM per user. Persistent so the notebook server survives
# between requests; a TTL is a backstop against leaks.
sbx = Sandbox.create(
    template="code-interpreter",  # scientific Python + Jupyter baked in
    persistent=True,
    ttl_seconds=8 * 3600,
    metadata={"user": "[email protected]"},
)

# The code-interpreter template already ships a Jupyter/IPython kernel,
# so there is no per-user pip install. Launch a notebook server bound
# to all interfaces inside the guest, with a per-user token.
start = sbx.exec(
    "jupyter server --ip=0.0.0.0 --port=8888 --no-browser "
    "--IdentityProvider.token='alice-secret-token' "
    ">/var/log/jupyter.log 2>&1 &",
    timeout_seconds=30,
)
print("launch exit:", start.exit_code)

# The notebook is now reachable on port 8888 inside this VM. PandaStack
# gives every sandbox a tokenless preview host for its lifetime:
#   https://8888-<sandbox-id>.<preview-suffix>/?token=alice-secret-token
# Put JupyterHub's proxy (or your own) in front of that, keyed by user.
print("sandbox:", sbx.id)

That preview URL is the seam where JupyterHub plugs in. In a custom spawner, you return the VM's notebook address from `start()` and JupyterHub's configurable proxy routes the authenticated user to it — the Hub never knows or cares that the room behind the door is a VM instead of a pod. You keep JupyterHub's mature auth and lose the shared-kernel boundary.

If you'd rather skip the notebook UI entirely and drive kernels programmatically — the shape most "run this cell" product features actually want — you exec against the sandbox directly and read artifacts back through the filesystem API:

from pandastack import Sandbox

cell = """
import pandas as pd, matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

df = pd.DataFrame({"day": range(30), "signups": [i*i % 47 for i in range(30)]})
df.to_parquet("/workspace/state.parquet")          # persist across cells
df.plot(x="day", y="signups", title="signups")
plt.savefig("/workspace/plot.png", bbox_inches="tight")
print("rows:", len(df), "total:", int(df.signups.sum()))
"""

with Sandbox.create(template="code-interpreter", ttl_seconds=600) as sbx:
    sbx.filesystem.write("/workspace/cell.py", cell)
    r = sbx.exec("python3 /workspace/cell.py", timeout_seconds=60)
    assert r.exit_code == 0, r.stderr
    print(r.stdout)

    # Pull the generated chart back out as bytes to render in your UI.
    png = sbx.filesystem.read("/workspace/plot.png")
    with open("plot.png", "wb") as f:
        f.write(png)
    print(f"pulled {len(png)} bytes")
# VM is destroyed on block exit — no state leaks to the next user
One VM per user is the whole point — never route two different users into the same sandbox. The isolation boundary is the VM; sharing one across tenants (a shared long-lived kernel for "efficiency") throws away exactly the property you paid for. Kill and recreate, or fork per session, at trust-domain boundaries.

Noisy neighbors and per-user resource isolation

Isolation isn't only about security — it's about one user's runaway `while True` or 40GB dataframe not freezing everyone else. Here the spawners diverge again. LocalProcessSpawner gives you essentially nothing; a fork bomb or memory balloon in one notebook takes the host and every user with it. DockerSpawner and KubeSpawner do much better: cgroup CPU/memory limits are real and enforced by the kernel, and Kubernetes adds requests/limits, eviction, and node autoscaling on top.

A microVM enforces resource limits at the hardware-virtualization layer: each guest sees a fixed vCPU count and RAM ceiling it physically cannot exceed, because it's a separate machine. There's no shared page cache to poison, no shared kernel scheduler for one tenant to monopolize. The trade-off is that a VM's RAM is committed to its guest rather than elastically shared the way container memory is — which is why the interesting question is what an idle notebook costs, not a busy one.

Idle economics: culling vs snapshot-restore and hibernate

Notebook workloads are almost all idle. A user opens a notebook, runs a few cells, then goes to lunch with the kernel sitting there holding RAM. JupyterHub's answer is the idle culler: after N minutes of inactivity it stops the user's server, freeing the container/pod. The cost is the next visit — the user comes back to a cold spawn and waits for a fresh container or pod (and image pull, and kernel start) to come up.

The microVM model attacks the same problem from the snapshot side. Because every create restores a baked Firecracker snapshot rather than cold-booting, spinning a user's environment back up is fast enough to do per request: on PandaStack a create is ~49ms of restore work, p50 179ms end to end, ~203ms at p99, versus roughly 3s for a genuine first cold boot. So you can be aggressive about reclaiming idle VMs without punishing the user on return.

Better still, you can hibernate instead of destroy. `hibernate()` snapshots the VM's memory and disk and stops it — the user's live kernel state, loaded dataframes and all, is frozen to disk and the RAM is released. The next request auto-wakes it from the snapshot. That's scale-to-zero for notebooks: an idle user costs storage, not RAM, and wakes back into exactly the session they left, not a blank kernel. Idle culling frees resources but loses state; hibernate frees resources and keeps it.

from pandastack import Sandbox

# Reattach to a user's existing notebook VM by id (from your own mapping).
sbx = Sandbox.get("sbx_alice_notebook")

# User went idle: snapshot memory+disk and stop the VM. RAM is freed;
# the live kernel (loaded dataframes, imported modules) is preserved.
sbx.hibernate()

# ... hours later, the user comes back ...

# Wake resumes from the snapshot into the exact prior state.
sbx.wake()
r = sbx.exec("python3 -c 'import pandas; print(pandas.__version__)'",
             timeout_seconds=15)
print("woke, pandas:", r.stdout.strip())

There's a related trick for provisioning: if every user should start from the same fully-configured environment (your libraries, your datasets mounted, your kernel warmed), snapshot one golden sandbox and fork it per user. A same-host fork is 400–750ms and shares the parent's memory copy-on-write; cross-host is 1.2–3.5s. That's meaningfully faster and denser than each user cold-installing their environment, which is the container-spawner default unless you've baked a fat image.

Side by side: the four models

  • Tenant isolation — LocalProcess: shared kernel, shared host, minimal. Docker/Kube spawner: container namespaces on a shared host kernel. microVM: hardware-virtualized, separate guest kernel per user.
  • Untrusted / public users — LocalProcess: no. Docker/Kube spawner: risky (shared-kernel escape crosses tenants). microVM: designed for it.
  • Noisy-neighbor limits — LocalProcess: none to speak of. Docker/Kube spawner: cgroup CPU/mem caps (strong). microVM: hardware-enforced fixed vCPU/RAM per guest.
  • Cold return after idle — LocalProcess: fast but no isolation. Docker/Kube spawner: fresh container/pod spawn (+ possible image pull). microVM: snapshot restore ~49ms; hibernate wakes into preserved live state.
  • Provisioning a golden env — LocalProcess: shared install. Docker/Kube spawner: bake a fat image, or install per user. microVM: snapshot once, fork per user (400–750ms same-host, copy-on-write memory).
  • Auth / proxy / user mgmt — LocalProcess/Docker/Kube: JupyterHub gives you all of it. microVM: keep JupyterHub in front; the VM is just what the spawner spawns.
  • Best fit — LocalProcess: one trusted team, one box. Docker/Kube spawner: trusted internal users, mature ops. microVM: untrusted, public, or per-customer multi-tenant.

So which should you use?

Use JupyterHub with a container spawner when your users are trusted. An internal data-science team, a company analytics platform, a research lab where everyone already has accounts and would have host access anyway — DockerSpawner or KubeSpawner is the right tool, and reaching for VM-per-user would be over-engineering. The shared-kernel risk is a risk you've already accepted elsewhere in your stack, and JupyterHub's operational maturity is worth a great deal.

Reach for microVM-per-user the moment the users are untrusted or the tenancy is external: a public notebook service, a coding-education platform running strangers' code, a SaaS that gives each customer an isolated analytics environment, or an AI product executing model-generated notebook cells. At that point a shared host kernel is a boundary you're betting other people's code against, and hardware isolation stops being over-engineering and starts being the point. And you don't have to give up JupyterHub to get there — put it in front of a microVM spawner and keep the front door you already trust.

For more on the underlying pattern, see running a Jupyter notebook inside a sandbox and giving each user their own notebook kernel in a microVM. If your driver is multi-tenant SaaS specifically, the microVM SaaS isolation guide goes deeper on the tenancy boundary.

Frequently asked questions

Is JupyterHub secure enough for untrusted multi-tenant users?

It depends entirely on the spawner, not on JupyterHub itself. LocalProcessSpawner shares one host kernel and filesystem across all users and is unsafe for untrusted code. DockerSpawner and KubeSpawner give real namespace and cgroup isolation but still share the host kernel, so a kernel bug or container escape can cross tenants. For genuinely untrusted or public users, run each notebook in its own hardware-isolated microVM (Firecracker) and keep JupyterHub as the auth and proxy layer in front.

Do I have to replace JupyterHub to use microVMs?

No — they're complementary. JupyterHub's authenticator and configurable proxy are the parts you want to keep. You swap only the spawner: instead of spawning a container or pod, spawn a microVM, boot a Jupyter server inside it, and return the VM's address to JupyterHub's proxy. Users still log in and get routed through the Hub; they just land in a VM with its own guest kernel instead of a shared-kernel container.

How do I run a Jupyter notebook inside a PandaStack sandbox?

Create a persistent sandbox on the code-interpreter template (which already bakes in a Jupyter/IPython kernel), then exec `jupyter server --ip=0.0.0.0 --port=8888 --no-browser` with a per-user token. Each sandbox gets a tokenless preview host on that port for its lifetime, which you route users to through JupyterHub's proxy or your own. For programmatic use, skip the UI and exec Python against the sandbox directly, reading plots and files back through the filesystem API.

What happens to an idle notebook's memory in each model?

JupyterHub's idle culler stops the user's server after inactivity, freeing the container or pod but discarding the live kernel state — the user returns to a cold spawn and a blank kernel. With microVMs you can hibernate instead: snapshot the VM's memory and disk, release the RAM, and auto-wake from the snapshot on the next request into the exact prior state, dataframes and all. That's scale-to-zero that preserves the session rather than throwing it away.

When is JupyterHub-on-containers the better choice than microVMs?

When your users are trusted. For an internal data-science team, a company analytics platform, or a research lab where everyone already has accounts and comparable access, DockerSpawner or KubeSpawner gives strong-enough isolation with excellent operational tooling, and per-user VMs would be over-engineering. Reserve microVM-per-user for untrusted, public, or external per-customer tenancy, where a shared host kernel is a boundary you're betting strangers' code against.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.