Back to Blog
Tutorial10 min read2026-06-29

How to Deploy a JupyterHub Notebook Server

JupyterHub gives a team multi-user, authenticated notebook access. This guide covers deploying a JupyterHub server, persisting notebooks, securing access, and the tradeoffs versus single-user Jupyter.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

A single Jupyter notebook server is fine for one person on a laptop. The moment a team needs shared, authenticated, persistent notebooks, you want JupyterHub — it spawns per-user notebook servers behind a single authenticated entry point. This guide deploys JupyterHub in a container with persistence and auth, and is honest about when the full Zero-to-JupyterHub Kubernetes setup is the better path.

JupyterHub vs. single-user Jupyter

NeedUse
One user, occasional useSingle jupyter notebook / jupyter lab
Small team, shared accessJupyterHub (container, this guide)
Large org, per-user isolation at scaleZero-to-JupyterHub on Kubernetes

We'll deploy JupyterHub with the simplest production-viable spawner. For demanding multi-tenant isolation, the official Helm chart (Z2JH) is the canonical answer — but it's heavyweight.

Step 1: Build a JupyterHub image

We'll use the local process spawner with a single shared environment — appropriate for a small, trusted team. The image bundles JupyterHub plus the notebook environment.

FROM python:3.12-slim

WORKDIR /srv/jupyterhub
ENV PYTHONUNBUFFERED=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    nodejs npm && rm -rf /var/lib/apt/lists/* \
  && npm install -g configurable-http-proxy

RUN pip install --no-cache-dir \
    jupyterhub==5.1.0 \
    jupyterlab==4.2.5 \
    notebook==7.2.2 \
    pandas numpy matplotlib

COPY jupyterhub_config.py .

EXPOSE 8000
CMD ["jupyterhub", "-f", "jupyterhub_config.py"]

configurable-http-proxy (a Node package) is required by JupyterHub for routing.

Step 2: Configure auth and storage

jupyterhub_config.py:

import os

c = get_config()  # noqa

c.JupyterHub.bind_url = "http://0.0.0.0:8000"

# Persist user notebooks under a mounted volume
c.Spawner.notebook_dir = "/data/{username}"
c.Spawner.default_url = "/lab"

# OAuth via your identity provider (recommended for teams)
from oauthenticator.generic import GenericOAuthenticator
c.JupyterHub.authenticator_class = GenericOAuthenticator
c.GenericOAuthenticator.client_id = os.environ["OAUTH_CLIENT_ID"]
c.GenericOAuthenticator.client_secret = os.environ["OAUTH_CLIENT_SECRET"]
c.GenericOAuthenticator.oauth_callback_url = os.environ["OAUTH_CALLBACK_URL"]
c.GenericOAuthenticator.authorize_url = os.environ["OAUTH_AUTHORIZE_URL"]
c.GenericOAuthenticator.token_url = os.environ["OAUTH_TOKEN_URL"]
c.GenericOAuthenticator.userdata_url = os.environ["OAUTH_USERDATA_URL"]

# Restrict to an allowlist
c.Authenticator.allowed_users = set(os.environ.get("ALLOWED_USERS", "").split(","))

Add oauthenticator to your pip install if you use OAuth. For a tiny trusted team, you could use the native authenticator with explicit accounts, but OAuth against your existing IdP is cleaner and safer.

Step 3: Persist notebooks — critical

Notebooks live on disk. In a container, that disk is ephemeral, so without a volume, everyone loses their work on every redeploy. Attach a persistent volume mounted at /data (matching notebook_dir above). This is the single most important production step.

Step 4: Deploy on PandaStack

  1. 1Push the repo with the Dockerfile and config to GitHub.
  2. 2Create a container app on [PandaStack](https://dashboard.pandastack.io) — rootless BuildKit builds the image.
  3. 3Choose a tier with enough RAM for your workloads; data science notebooks are memory-hungry, so a memory-optimized (m1/m2) tier is often right.
  4. 4Attach a persistent volume at /data.
  5. 5Expose port 8000 and add OAuth env vars as secrets.
  6. 6Add a custom domain — SSL is automatic, and the OAuth callback URL must match it.

Don't scale-to-zero a notebook server with active users — losing the server mid-session interrupts running kernels. Keep it warm during working hours.

Step 5: Resource limits

Notebook users can accidentally consume enormous memory (a runaway pd.read_csv on a huge file). Set spawner memory/CPU limits so one user can't starve the others:

c.Spawner.mem_limit = "4G"
c.Spawner.cpu_limit = 2

When to graduate to Zero-to-JupyterHub

The single-container approach shares one environment and one machine across users. When you need true per-user isolation, per-user resource quotas, and elastic scaling, move to the official [Zero-to-JupyterHub](https://z2jh.jupyter.org/) Helm chart on a dedicated Kubernetes cluster. It's more to operate but is the right tool for large teams.

Security checklist

  • OAuth + an explicit user allowlist — never leave the hub open.
  • Per-user memory/CPU limits.
  • Persistent volume backed up (export important notebooks to git too).
  • HTTPS only (automatic via the platform).
  • Keep JupyterLab and dependencies patched.

References

  • [JupyterHub documentation](https://jupyterhub.readthedocs.io/)
  • [Zero to JupyterHub (Kubernetes)](https://z2jh.jupyter.org/)
  • [OAuthenticator](https://oauthenticator.readthedocs.io/)
  • [JupyterHub configuration reference](https://jupyterhub.readthedocs.io/en/stable/reference/config-reference.html)

---

For a small data team, a single JupyterHub container with OAuth and a persistent volume hits the sweet spot of shared access without operational overhead. PandaStack provides the memory-optimized compute and persistent storage it needs. Start at [dashboard.pandastack.io](https://dashboard.pandastack.io).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also