Deploy a JupyterHub Classroom
- How do I deploy a JupyterHub of my own on NRP?
- How do I give students a menu of environments and resource sizes?
- What changes when the hub is for a real course?
- Deploy JupyterHub with Helm from a values file, on a public HTTPS hostname.
- Add image profiles, per-profile resource limits, and shared class storage.
- Know the one thing that must change before a real course goes live.
▶ Open the notebook for this section — optional; every block below is a cell you can run yourself. yamls/jhub-values.yaml for this section is in the workspace.
Time: 00:15–00:50
Deploy your own multi-user JupyterHub with Helm — controlled access, custom images, per-profile resource limits, shared storage. This is the recipe instructors and PIs use to stand up course and lab hubs on NRP, and the whole thing is driven from a notebook.
This section takes a deliberate shortcut so it fits in the time we have. Every hub you deploy after today should follow the documented path in Deploy JupyterHub, and the difference that matters is authentication.
| This demo | A hub you actually run | |
|---|---|---|
| Authenticator | DummyAuthenticator | CILogonOAuthenticator |
| Who can sign in | anyone who knows the shared password | your campus IdP, narrowed by allowed_idps / allowed_users |
| Prerequisite | none | an OAuth client registered with CILogon |
| Lead time | zero | plan on several days to more than a week |
DummyAuthenticator is a password in a values file. It is fine for a throwaway namespace for 40 minutes; it is not acceptable for a hub with a public hostname — NRP's docs are blunt that leaving a hub open can get your namespace locked.
CILogon is an independent service, not operated by NRP; you register your own OAuth client at cilogon.org/oauth2/register and staff review it by hand. Start that registration well before the term, and pick your hostname first — it is baked into the callback URL.
Everything else here — Helm, the values file, profiles, resource limits, shared storage — is identical either way. Only the hub.config authentication block changes (5.4).
The demo hub has kubectl, helm and your cluster login preconfigured. To run the same commands from your own laptop later you will need kubectl, the kubelogin plugin, helm and your Nautilus kubeconfig — see cluster access via kubectl — and you must be an admin of the namespace you deploy into.
Setup — claim your namespace
Each participant works in their own pre-created namespace (nrp-training-000 … nrp-training-099) — JupyterHub can only be deployed once per namespace. Set your short username, render your personal manifests, and claim your namespace in one step. The claim is keyed by your hub login, so it is idempotent — you get the same slot back every time, and re-running it after a break is safe:
export NRP_USER=changeme # ✏️ EDIT to your short name
cd ~/nairr-classroom/workspace
if [ "$NRP_USER" = changeme ]; then echo "⚠️ Edit NRP_USER above first, then re-run"; else
mkdir -p my-yamls
for f in yamls/*; do sed "s/<username>/$NRP_USER/g" "$f" > "my-yamls/$(basename "$f")"; done
echo "✅ my-yamls/ rendered for $NRP_USER"
fi
# claim your own namespace for the session (idempotent — same slot every time you ask):
export NRP_NAMESPACE=$(curl -s "http://nrp-claim.nrp-training.svc.cluster.local/claim?user=${JUPYTERHUB_USER:-$NRP_USER}")
export NRP_RELEASE=jhub-$NRP_USER
echo "namespace=$NRP_NAMESPACE release=$NRP_RELEASE"Expected output
✅ my-yamls/ rendered for nautilus
namespace=nrp-training-042 release=jhub-nautilus$NRP_NAMESPACE and $NRP_RELEASE are what the commands below (and check.sh 2) pick up — no hand-editing. Every manifest is rendered into my-yamls/ with <username> already filled in, so wherever this page says "replace <username>", it is already done in your copy.
Terminal sessions don't share these variables — run the same
exportlines in any terminal you open. Re-running the render overwrites edits you made inmy-yamls/.
📘 Docs: Deploy JupyterHub · Build images · NRP GitLab CI · Z2JH (upstream)
1. Helm in one paragraph
Helm is a package manager for Kubernetes — instead of authoring every Deployment, Service, and ConfigMap by hand, you install a chart (a reusable bundle of templates) and tune it through a values file. The Zero to JupyterHub chart packages the entire hub/proxy/spawner stack; your whole deployment is one YAML file of values.
The values file looks long until you see what it stands in for. Your 173 lines render into thirteen Kubernetes objects, about 1,400 lines of manifests:
And they all have to agree with each other. The hub's Deployment carries a checksum of the ConfigMap and the Secret, so changing either restarts the hub instead of leaving it running on stale config. The ServiceAccount needs a Role granting create and delete on pods and PVCs — that is how the hub spawns a user's server. proxy-public has to route to the proxy, which has to route to the hub, which has to know its own public URL.
Hand-written, changing the single-user image means editing several files and re-checking every reference between them. With the chart it is one line in one file, and helm upgrade works out what has to change.
In the training hub, helm is preinstalled — verify, then add the chart repository:
kubectl auth whoami && helm version --short
helm repo add jupyterhub https://jupyterhub.github.io/helm-chart/
helm repo update
helm repo listExpected output
"jupyterhub" has been added to your repositories
Update Complete. ⎈Happy Helming!⎈
NAME URL
jupyterhub https://jupyterhub.github.io/helm-chart/2. Examine the values file
The chart brings the templates; this one file brings every decision. Read it a block at a time — the whole thing is at the end of the section.
2.1 Who can log in
hub:
config:
JupyterHub:
authenticator_class: dummy
admin_access: true
admin_users: ["admin", "admin2"]
DummyAuthenticator:
password: "training123"
# Allow all users to sign in (for tutorial purposes)
Authenticator:
allowed_users: set()authenticator_class: dummy accepts any username with the shared password — which is why this is a demo hub and not a course hub. admin_users gets the admin panel: other people's servers, the user list, a shutdown button. There are two of them so you can be two people at once later — 5.3 has you log in as both to watch a shared folder work. allowed_users: set() is an empty allowlist, and empty here means "no list — let everyone in".
That last line is the first thing to change in production. With CILogon (5.4) the allowlist stops being a formality and becomes your enrollment list.
2.2 The hub
hub:
db:
type: sqlite-pvc
pvc:
accessModes: [ReadWriteOnce]
storage: 1Gi
storageClassName: rook-ceph-block-east
resources:
limits: {cpu: "2", memory: 1Gi}
requests: {cpu: 100m, memory: 512Mi}The hub process keeps its state — users, running servers, API tokens — in SQLite on its own 1Gi volume, so restarting the hub does not lose who is logged in. rook-ceph-block-east is NRP block storage; ReadWriteOnce is all a single database pod needs.
2.3 The proxy
proxy:
secretToken: 'secret_token'
service:
type: ClusterIPEvery request to a user's server goes through the proxy, and secretToken is the shared secret the hub uses to reprogram its routes. secret_token is a placeholder that must never reach a real deployment — the install step in section 3 mints a real one into your copy before Helm sees it.
ClusterIP keeps the proxy inside the cluster; the ingress (2.6) is what faces the internet.
2.4 The single-user servers
Abridged to the decisions you will actually change — the environment, the size, and the home directory:
singleuser:
image: # the default environment
name: quay.io/jupyter/scipy-notebook
tag: 2024-04-22
cpu: # what every server gets
limit: 3
guarantee: 3
memory:
limit: 10G
guarantee: 10G
storage: # a private home volume per user
type: dynamic
capacity: 5Gi
homeMountPath: /home/jovyan
dynamic:
storageClass: rook-ceph-block-east
pvcNameTemplate: claim-{username}{servername}
defaultUrl: "/lab"
profileList: # the spawn-page menu — more in 5.1
- display_name: Scipy
kubespawner_override:
image_spec: quay.io/jupyter/scipy-notebook:2024-04-22
default: Trueimage is the environment a server starts in — pin the tag, never latest. cpu/memory apply to every server, and setting guarantee equal to limit reserves the resources instead of overcommitting them. storage: dynamic gives each user their own PVC, named from pvcNameTemplate and mounted at homeMountPath, so /home/jovyan survives logouts, restarts and culls. profileList is the menu on the spawn page; each entry's kubespawner_override replaces the defaults above. The file ships with fifteen profiles — one is shown here.
2.5 Culling idle servers
cull:
enabled: true
timeout: 3600
every: 600Every 10 minutes (every) the culler shuts down servers idle for more than an hour (timeout). Home volumes are untouched, so a student logs back in and picks up where they left off.
A student who closes their laptop lid leaves a pod holding CPU and memory. On shared national infrastructure that is the fastest way to make your namespace unpopular — and for a class of 40, it is the difference between a hub that fits its allocation and one that does not.
2.6 The ingress
ingress:
enabled: true
ingressClassName: haproxy
hosts: ["jhub-<username>.nrp-nautilus.io"]
pathSuffix: ''
tls:
- hosts:
- jhub-<username>.nrp-nautilus.ioThis is what puts the hub on the public internet, and it goes in from the start — no second deploy to expose it. The hostname has to be globally unique, which is why the setup step substituted <username> for you. ingressClassName: haproxy hands routing to the cluster's HAProxy controller, and the tls block makes cert-manager request a Let's Encrypt certificate for that name — no certificate files for you to manage.
The whole file
Those are the blocks worth explaining; the rest is node affinity, image pre-pullers and scheduler settings you can leave alone. Read it end to end in the workspace at yamls/jhub-values.yaml, or on GitHub.
3. Deploy
First — what is already running in your namespace?
JupyterHub can only be deployed once per namespace: a second release fights the first over the proxy-public service and the hub database. Your claimed slot should be empty, but check before you install.
helm list -n $NRP_NAMESPACE
kubectl get pods -n $NRP_NAMESPACEExpected output
NAME NAMESPACE REVISION STATUS CHART APP VERSION
No resources found in nrp-training-042 namespace.An empty helm list and no pods means you are clear — deploy.
If a release is listed, look at the NAME column. Tear it down only if it is yours; in a shared namespace someone else's class may be running on it.
Clear an existing JupyterHub
OLD_RELEASE=changeme # ✏️ the NAME shown by `helm list` above
helm uninstall "$OLD_RELEASE" -n $NRP_NAMESPACE
kubectl wait --for=delete pod -l app=jupyterhub -n $NRP_NAMESPACE --timeout=120s 2>/dev/null || true
helm list -n $NRP_NAMESPACE
kubectl get pods -n $NRP_NAMESPACEhelm uninstall leaves PVCs behind on purpose — the hub database and any user home directories survive, so a reinstall picks them back up. Section 6 shows how to delete those too.
Install the chart
# mint a proxy secret token — only replaces the placeholder, so re-runs keep the same token
if grep -q "'secret_token'" my-yamls/jhub-values.yaml; then
sed -i "s|secretToken: .*|secretToken: '$(openssl rand -hex 32)'|" my-yamls/jhub-values.yaml
fi
helm upgrade --cleanup-on-fail --install $NRP_RELEASE jupyterhub/jupyterhub \
--namespace $NRP_NAMESPACE \
--values my-yamls/jhub-values.yaml \
--wait \
--timeout=10mExpected output
Release "jhub-nautilus" does not exist. Installing it now.
NAME: jhub-nautilus
NAMESPACE: nrp-training-042
STATUS: deployed
REVISION: 1
NOTES:
You have successfully installed the official JupyterHub Helm chart!Inspect what the chart created — every one of these is an ordinary Kubernetes object:
kubectl get pods -n $NRP_NAMESPACEkubectl get services -n $NRP_NAMESPACEkubectl get pvc -n $NRP_NAMESPACEkubectl get ingress -n $NRP_NAMESPACEYou should see the hub pod (auth, sessions, spawning), the proxy pod (routing), a hub-db-dir PVC, an ingress carrying your hostname — and, once someone logs in, per-user pods and claim-<user> PVCs.
Log in
After ~a minute for HAProxy and Let's Encrypt, open https://jhub-$NRP_USER.nrp-nautilus.io, log in as admin with the Dummy password (training123), and spawn a server. admin2 is a second account on the same password — you will need it in 5.3. You now have a working multi-user JupyterHub on national research infrastructure.

4. Operating your hub
helm list -n $NRP_NAMESPACEsleep 5
kubectl logs -n $NRP_NAMESPACE -l app=jupyterhub,component=hub --tail=50kubectl get pods -n $NRP_NAMESPACE -l app=jupyterhub,component=singleuser-serverTroubleshooting is the standard Kubernetes trio: describe the failing pod, read namespace events, check hub/proxy logs.
Check your work at any point:
bash check.sh 2That is a complete, working, publicly reachable JupyterHub — if this is as far as you need to go today, tear it down in 6. Cleanup so the namespace is left clean.
Staying on? Section 5 turns it into your hub: an image menu, per-profile sizes, a folder the whole class shares. Nothing below is required for the hub you already have.
5. Make it yours
Your hub is running, so every change below is one you can make right now. They all follow the same pattern: write a small overlay file holding just the change, then hand Helm both files.
helm upgrade $NRP_RELEASE jupyterhub/jupyterhub \
--namespace $NRP_NAMESPACE \
--values my-yamls/jhub-values.yaml \
--values my-yamls/<overlay>.yaml \
--wait --timeout=10m--values can be passed as many times as you like. Helm merges the files rather than replacing one with the next, so the second file is not a replacement for the first — it is a patch on top of it:
| In the base file | In the overlay | Result |
|---|---|---|
cull.timeout: 3600 | not mentioned | kept |
singleuser.image | singleuser.image | the overlay's value wins |
profileList — 15 entries | profileList — 2 entries | replaced, not appended |
Maps merge key by key, at any depth: an overlay that sets one field inside singleuser.storage leaves the rest of singleuser alone. Lists are the exception — they replace wholesale, which is why an overlay's profileList becomes the entire menu rather than a longer one. Order matters, so the overlay goes last.
This is worth keeping past today. Your base file is the hub you agreed to run, and it stays in version control untouched; each change is a small file that reads as a diff of one decision — a GPU profile for the deep-learning unit, this term's dataset mounted, a longer cull timeout during finals week. Rolling one back is deleting a flag rather than editing YAML under pressure, and stacking several is just more --values.
When you come back to a hub months later and cannot remember what it is actually running with, ask it:
helm get values $NRP_RELEASE -n $NRP_NAMESPACE # what you supplied
helm get values $NRP_RELEASE -n $NRP_NAMESPACE -a # everything, chart defaults includedIn the live session we run 5.1 and 5.3. 5.2 (per-profile sizes) and 5.4 (real authentication) are here for you to read or try afterwards.
One thing to know before you start: each command below layers only its own overlay, so it undoes the previous experiment. Pass several --values flags to stack them.
5.1 Multiple image profiles
The file already ships with fifteen profiles. Replace them with a shorter menu — this is the singleuser.profileList from 2.4:
singleuser:
profileList:
- display_name: Scipy
kubespawner_override:
image_spec: quay.io/jupyter/scipy-notebook:2024-04-22
default: True
- display_name: Tensorflow (CUDA)
kubespawner_override:
image_spec: quay.io/jupyter/tensorflow-notebook:cuda-2024-04-22
- display_name: Pytorch (CUDA 12)
kubespawner_override:
image_spec: quay.io/jupyter/pytorch-notebook:cuda12-2024-04-22
- display_name: Datascience (scipy, Julia, R)
kubespawner_override:
image_spec: quay.io/jupyter/datascience-notebook:2024-04-22Try it:
cat > my-yamls/overlay-profiles.yaml <<'EOF'
singleuser:
profileList:
- display_name: Scipy
kubespawner_override:
image_spec: quay.io/jupyter/scipy-notebook:2024-04-22
default: True
- display_name: Tensorflow (CUDA)
kubespawner_override:
image_spec: quay.io/jupyter/tensorflow-notebook:cuda-2024-04-22
- display_name: Pytorch (CUDA 12)
kubespawner_override:
image_spec: quay.io/jupyter/pytorch-notebook:cuda12-2024-04-22
- display_name: Datascience (scipy, Julia, R)
kubespawner_override:
image_spec: quay.io/jupyter/datascience-notebook:2024-04-22
EOF
helm upgrade $NRP_RELEASE jupyterhub/jupyterhub \
--namespace $NRP_NAMESPACE \
--values my-yamls/jhub-values.yaml \
--values my-yamls/overlay-profiles.yaml \
--wait --timeout=10mReload the spawn page — the menu is four entries now. A server that is already running keeps its old image until you stop and restart it.
5.2 Per-profile resource limits
Each entry's kubespawner_override can set size as well as image, which is how one hub serves an intro unit and a deep-learning unit at once:
- display_name: Small (2 CPU, 4GB RAM)
kubespawner_override:
image_spec: quay.io/jupyter/scipy-notebook:2024-04-22
cpu_limit: 2
cpu_guarantee: 2
mem_limit: 4G
mem_guarantee: 4G
- display_name: Large (8 CPU, 16GB RAM)
kubespawner_override:
image_spec: quay.io/jupyter/scipy-notebook:2024-04-22
cpu_limit: 8
cpu_guarantee: 8
mem_limit: 16G
mem_guarantee: 16GTry it:
cat > my-yamls/overlay-sizes.yaml <<'EOF'
singleuser:
profileList:
- display_name: Small (2 CPU, 4GB RAM)
default: True
kubespawner_override:
image_spec: quay.io/jupyter/scipy-notebook:2024-04-22
cpu_limit: 2
cpu_guarantee: 2
mem_limit: 4G
mem_guarantee: 4G
- display_name: Large (8 CPU, 16GB RAM)
kubespawner_override:
image_spec: quay.io/jupyter/scipy-notebook:2024-04-22
cpu_limit: 8
cpu_guarantee: 8
mem_limit: 16G
mem_guarantee: 16G
EOF
helm upgrade $NRP_RELEASE jupyterhub/jupyterhub \
--namespace $NRP_NAMESPACE \
--values my-yamls/jhub-values.yaml \
--values my-yamls/overlay-sizes.yaml \
--wait --timeout=10mReload the spawn page and pick Small — then check what the pod actually got:
kubectl get pod -n $NRP_NAMESPACE -l app=jupyterhub,component=singleuser-server \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].resources}{"\n"}{end}'A GPU profile adds extra_resource_limits: {"nvidia.com/gpu": "1"}.

This is where a course gets shaped. Give the intro unit a Small CPU profile and the deep-learning unit a GPU profile, and students pick the right one from a dropdown instead of you managing machines — or fielding "how much memory should I ask for?" forty times.
5.3 Shared storage for the whole class
Mount one RWX CephFS volume into every user server:
singleuser:
storage:
extraVolumes:
- name: jupyterhub-shared
persistentVolumeClaim:
claimName: jupyterhub-shared-volume
extraVolumeMounts:
- name: jupyterhub-shared
mountPath: /home/sharedThis one needs a volume to mount, so create the claim first. rook-cephfs is the RWX class — rook-ceph-block-east, which your home directories use, only attaches to one pod at a time and would fail the moment a second student spawned.
Try it:
kubectl apply -n $NRP_NAMESPACE -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jupyterhub-shared-volume
spec:
storageClassName: rook-cephfs
accessModes: [ReadWriteMany]
resources:
requests:
storage: 5Gi
EOF
kubectl get pvc jupyterhub-shared-volume -n $NRP_NAMESPACE
cat > my-yamls/overlay-shared.yaml <<'EOF'
singleuser:
storage:
extraVolumes:
- name: jupyterhub-shared
persistentVolumeClaim:
claimName: jupyterhub-shared-volume
extraVolumeMounts:
- name: jupyterhub-shared
mountPath: /home/shared
EOF
helm upgrade $NRP_RELEASE jupyterhub/jupyterhub \
--namespace $NRP_NAMESPACE \
--values my-yamls/jhub-values.yaml \
--values my-yamls/overlay-shared.yaml \
--wait --timeout=10mStop and restart your server from the hub's control panel — a mount only appears in a pod that starts with it — and /home/shared is there in the file browser.
Now prove it is actually shared. One browser session is one logged-in user, so being two people at once needs a second session:
- As
admin, open a terminal in your hub (File ▸ New ▸ Terminal) and drop a file in:
``bash echo "hello from admin" > /home/shared/hello.txt ``
- Open a private/incognito window on the same
https://jhub-$NRP_USER.nrp-nautilus.io, and log in asadmin2with the same password. Spawn a server.
admin2gets its own empty home directory — but/home/shared/hello.txtis right there, and editing it from either account shows up in the other.
That is the classroom pattern in miniature: private homes, one common folder. Instructors drop datasets and notebooks in once; every student sees them instantly. Mount it read-only for students in production.
5.4 Real authentication
For production, replace the Dummy authenticator with institutional login: campus credentials, no passwords to distribute, and a list of who is allowed in that you control. yamls/cilogon-jupyterhub-config.yaml in the workspace is a working CILogonOAuthenticator config.
This is the one change on this page we cannot try live — it needs OAuth credentials that CILogon issues by hand, which is why today's hub uses the Dummy authenticator at all. The pattern you will write once those arrive is a class roster: campus login gates the door, and the enrollment list decides who gets through it.
hub:
config:
JupyterHub:
authenticator_class: cilogon
CILogonOAuthenticator:
client_id: <OIDC client>
client_secret: <OIDC secret>
oauth_callback_url: https://<your-name>.nrp-nautilus.io/hub/oauth_callback
# IDP Lookup: https://cilogon.org/idplist/
allowed_idps:
https://shib.your-institution.edu/idp/shibboleth:
username_derivation:
username_claim: email
admin_users:
- you@your-institution.edu
allowed_users:
- student1@your-institution.edu
- student2@your-institution.eduThe key under allowed_idps is your identity provider's entity ID, and the format varies a lot from campus to campus. Find yours in the CILogon IdP list and paste it exactly; a key CILogon does not recognise simply never matches a login, and nobody gets in.
Allow rules are additive (allowed_domains admits a whole campus, allowed_users admits named people) and blocked_users wins over all of them. Students who drop the course lose access the next time you helm upgrade.
Putting it back
Drop the overlay flags and your hub returns to the base file:
helm upgrade $NRP_RELEASE jupyterhub/jupyterhub \
--namespace $NRP_NAMESPACE \
--values my-yamls/jhub-values.yaml \
--wait --timeout=10m6. Cleanup
If this was a trial run, uninstall your Helm release so the cluster is left clean:
helm uninstall $NRP_RELEASE -n $NRP_NAMESPACEUser PVCs are kept by default; delete them only if you are sure:
kubectl delete pvc -n $NRP_NAMESPACE -l app=jupyterhub,component=singleuser-storagehelm uninstall does not touch the shared volume either, because nothing in the release owns it — if you created it in 5.3, it is still there:
kubectl delete pvc jupyterhub-shared-volume -n $NRP_NAMESPACE --ignore-not-foundIf this is a real course hub, leave it running — the cull settings close idle student sessions automatically.
7. Taking it home
A hub you run for a course differs from today's in a handful of specific places, and NRP documents the whole path:
- Register an OAuth client with CILogon first — callback URL
https://<your-name>.nrp-nautilus.io/hub/oauth_callback, client type Confidential, scopesorg.cilogon.userinfo,openid,profile,email. Days to more than a week. - Fill in the authentication block (5.4), set
authenticator_class: cilogon, and replace the placeholderproxy.secretTokenwithopenssl rand -hex 32. - Install it the same way you did today — same chart, your values file, a namespace you are admin of. Pin
--versionin production so a chart release never changes the hub underneath your students mid-semester.
Our demo hub has allowed_users: set() — an empty allowlist that lets anyone sign in. A real hub narrows access with allowed_idps or an explicit allowed_users list — for a course, your enrollment roster. cull is not optional either: deploying without it is against cluster policy, and timeout may not exceed 21600 (6 hours).
Custom course images. The stock Jupyter images only go so far; real courses need their own package stacks. NRP GitLab (gitlab.nrp-nautilus.io) builds an image from a Dockerfile in CI and hosts it in its registry, and you point a hub profile at it with image_spec: gitlab-registry.nrp-nautilus.io/<group>/<project>:<tag>. Tag with the commit SHA, not just latest, so the environment never shifts under students mid-semester. The step-by-step is in NRP's Build images and GitLab CI guides.
Where to go next
- Get your own namespace if you were following along on the demo hub — see Getting your own access.
- Start the CILogon registration if a real course hub is in your plans — it is the longest-lead item in this entire session.
- Docs: nrp.ai/documentation
- Live help: the NRP Matrix channel at nrp.ai/contact
- These materials: training.nrp-nautilus.io and GitHub
Open Q&A — your own course, GPU and allocation policy, migrating an existing class onto NRP. Ask away.
- The values file is your deployment — version-control it and you can rebuild anywhere.
helm upgradere-renders the chart with new values; it is notkubectl apply.- One RWX volume mounted into every server is how a class shares datasets.
- A real course hub uses CILogon, not the demo password — register for it early.