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
| Need | Use |
|---|---|
| One user, occasional use | Single jupyter notebook / jupyter lab |
| Small team, shared access | JupyterHub (container, this guide) |
| Large org, per-user isolation at scale | Zero-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
- 1Push the repo with the Dockerfile and config to GitHub.
- 2Create a container app on [PandaStack](https://dashboard.pandastack.io) — rootless BuildKit builds the image.
- 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.
- 4Attach a persistent volume at
/data. - 5Expose port
8000and add OAuth env vars as secrets. - 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 = 2When 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).