Deploy a Custom JupyterHub & Build Course Images
- How do I deploy a JupyterHub of my own on NRP?
- How do I give students a menu of environments and resource sizes?
- How do I build a custom course image and keep it stable all semester?
- Deploy JupyterHub with Helm from a values file, on a public HTTPS hostname.
- Add image profiles, per-profile resource limits, and shared class storage.
- Build a custom course image with NRP GitLab CI/CD.
▶ Open the notebook for this section — yamls/jhub-values.yaml for this section is in the workspace.
Time: 00:50–01:30
Deploy your own JupyterHub with Helm — controlled access, custom images, per-profile resource limits, shared storage — then see how to build custom container images with NRP GitLab CI/CD. This is the recipe instructors and PIs use to stand up course and lab hubs on NRP.
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 workshop | 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 about this: leaving a hub open for anyone to sign in can get your namespace locked.
The real path uses CILogon, the same federated login NRP itself uses — your students sign in with their existing campus credentials. The catch is that CILogon is an independent service, not operated by NRP, and you register your own OAuth client with them at cilogon.org/oauth2/register:
- Callback URL:
https://<your-hostname>.nrp-nautilus.io/hub/oauth_callback - Client type: Confidential · Refresh tokens: No
- Scopes:
org.cilogon.userinfo,openid,profile,email
CILogon staff review each registration by hand and email you a client ID and secret once approved — budget a few days, and it can stretch past a week. Two consequences for planning a course:
- Start the registration well before the term. It is the long pole, and nothing on the NRP side unblocks it.
- Pick your hostname first. It is baked into the callback URL you register, so changing it later means going back to CILogon.
Everything else on this page — Helm, the values file, profiles, resource limits, shared storage, custom images — is identical either way. Only the hub.config authentication block changes, plus an allowed_idps allowlist for your institution. yamls/cilogon-jupyterhub-config.yaml in the workspace is a working example of that block — see 5.4 Real authentication.
The training hub has all of this preinstalled, so nothing below is needed today. To run the same commands from your laptop against your own namespace, you need three tools and the cluster config:
| What | Why | Where |
|---|---|---|
kubectl | talks to the Kubernetes API — every kubectl command on this page | kubernetes.io/docs/tasks/tools |
kubelogin | CILogon/OIDC login for kubectl. The NRP kubeconfig does not work without it | github.com/int128/kubelogin |
helm | installs and upgrades the JupyterHub chart | helm.sh/docs/intro/install |
| Nautilus kubeconfig | points kubectl at Nautilus and carries your CILogon identity — save it as ~/.kube/config, no extension | nrp.ai/config |
kubelogin is a kubectl plugin, so the binary must land on your PATH under the name kubectl-oidc_login — that exact name is how kubectl finds it. The NRP docs give a copy-paste installer for Linux and macOS, plus fixes for headless machines, WSL, and port conflicts: cluster access via kubectl.
You also need to be an admin of the namespace you deploy into — a plain member cannot install a chart.
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 ~/hidsi/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 3) 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 workshop 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 3That 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 includedOne 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; what changes between a departmental hub and a locked-down course hub is only which allow rule you write.
This is the one change on this page you cannot try right now — it needs a client_id and client_secret that CILogon issues by hand, and that wait is the reason today's hub uses the Dummy authenticator at all. The patterns below are what you will write once those arrive.
The key under allowed_idps is your identity provider's entity ID, and the format varies a lot from campus to campus. Real ones:
https://shib.unl.edu/idp/shibboleth # Nebraska
https://idpz.utorauth.utoronto.ca/shibboleth # Toronto — not "shib.utoronto.ca"
http://google.com/accounts/o8/id # Google — http, and no hostname you would guess
https://github.com/login/oauth/authorize # GitHubThere is no pattern to derive yours from — look it up in the CILogon IdP list and paste it exactly. A key that does not match an entity ID CILogon knows simply never matches a login, and nobody gets in.
Everyone at one campus
Name your institution's identity provider and let any address on its domain in:
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
admin_users:
- you@your-institution.edu
# IDP Lookup: https://cilogon.org/idplist/
allowed_idps:
https://shib.your-institution.edu/idp/shibboleth:
allowed_domains:
- your-institution.edu
username_derivation:
username_claim: emailGood for a lab or departmental hub. allowed_domains takes shell-style wildcards, so "*.your-institution.edu" picks up cs.your-institution.edu and friends.
A class roster
Drop allowed_domains and name the people instead — the enrollment list is the config. Campus login still gates the door; the roster decides who gets through it:
CILogonOAuthenticator:
# …client_id, client_secret, oauth_callback_url as above…
# 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
- your-ta@your-institution.edu
allowed_users:
- student1@your-institution.edu
- student2@your-institution.edu
- student3@your-institution.eduStudents who drop the course lose access the next time you helm upgrade. Admins are allowed implicitly — you do not repeat them in allowed_users.
Both at once
Allow rules are additive: a user gets in if any rule admits them. So a course open to your campus plus a handful of outside collaborators is both rules together —
# IDP Lookup: https://cilogon.org/idplist/
allowed_idps:
https://shib.your-institution.edu/idp/shibboleth:
allowed_domains:
- your-institution.edu # anyone on campus…
username_derivation:
username_claim: email
allowed_users:
- collaborator@other-university.edu # …plus these specific people
blocked_users:
- former-student@your-institution.edublocked_users wins over every allow rule, which is how you remove one person without rewriting the roster.
Usernames are whatever claim you pick. username_claim: email makes the JupyterHub username jdoe@your-institution.edu, and that is the string your allowed_users entries and per-user PVC names must match. Adding action: strip_idp_domain with domain: your-institution.edu under username_derivation gives you a plain jdoe instead.
allowed_idps was renamed idps in oauthenticator 17.4. NRP's example file — and the snippets above, which follow it — still use allowed_idps; it is accepted as an alias and logs a deprecation warning, so prefer idps on a hub you are building fresh.
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. Deploying your own hub at NRP
Today's hub was built to fit a workshop: Dummy auth, a throwaway namespace, a chart version left floating. A hub you actually run for a course differs in a handful of specific places, and NRP documents the whole path:
The minimum that has to change
1. Register an OAuth client with CILogon at cilogon.org/oauth2/register. Pick your hostname first — it is baked into the callback URL:
| Field | Value |
|---|---|
| Callback URL | https://<your-name>.nrp-nautilus.io/hub/oauth_callback |
| Client type | Confidential |
| Scopes | org.cilogon.userinfo,openid,profile,email |
| Refresh tokens | No |
They review registrations by hand and email you a client ID and secret — days, sometimes more than a week. Start this before anything else.
2. Set six fields in your values file. yamls/cilogon-jupyterhub-config.yaml in your workspace is a working copy of NRP's example; these are the parts only you can fill in:
hub:
config:
CILogonOAuthenticator:
client_id: <OIDC client> # from CILogon
client_secret: <OIDC secret> # from CILogon
oauth_callback_url: https://<your-name>.nrp-nautilus.io/hub/oauth_callback
admin_users:
- you@your-institution.edu
JupyterHub:
authenticator_class: cilogon # not dummy
proxy:
secretToken: '<openssl rand -hex 32>'
httpRoute:
enabled: true
hostnames:
- <your-name>.nrp-nautilus.io
gateway:
name: ingress
namespace: haproxyhttpRoute is the Gateway API route NRP uses now; the ingress block you deployed with today still works but is marked for deprecation in NRP's own example, so new hubs should prefer httpRoute.
Our workshop hub has allowed_users: set() — an empty allowlist that lets anyone sign in. That is fine for a namespace that lives 40 minutes; on a public hostname with real login it is how your namespace gets locked. A real hub narrows access with allowed_idps (your institution's identity provider, from the CILogon IdP list) or an explicit allowed_users list — which, for a course, is your enrollment roster.
cull is not optional either: deploying without it is against cluster policy, and timeout may not exceed 21600 (6 hours).
3. Install it the same way you did today — same chart, your values file, a namespace you are admin of:
helm upgrade --cleanup-on-fail --install jhub jupyterhub/jupyterhub \
--namespace <your-namespace> \
--values config.yamlPin --version once you are in production so a chart release never changes the hub underneath your students mid-semester — the same reasoning as pinning image tags in section 8.
Everything else on this page — profiles, resource limits, shared storage, culling, custom images — is identical whether the hub is yours or a workshop's.
8. Building custom course images in NRP GitLab
This is the take-home section — if the workshop ran out of time, it is the part to read afterwards. Nothing earlier on this page depends on it.
The stock Jupyter images only go so far — real courses need their own package stacks. NRP GitLab (gitlab.nrp-nautilus.io) builds images for you in CI and hosts them in its container registry.
The workflow:
- Create a project on NRP GitLab and add a
Dockerfile— typicallyFROM quay.io/jupyter/scipy-notebook:…plus yourpip/condainstalls. - Add
.gitlab-ci.yml— a single Kaniko job builds and pushes on every commit:
image: ghcr.io/osscontainertools/kaniko:debug
stages:
- build-and-push
build-and-push-job:
stage: build-and-push
variables:
GODEBUG: "http2client=0"
script:
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor --cache=true --push-retry=10 --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA --destination $CI_REGISTRY_IMAGE:latest- Use the image anywhere on the cluster — in a pod spec, or as a hub profile:
- display_name: My Course Image
kubespawner_override:
image_spec: gitlab-registry.nrp-nautilus.io/<group>/<project>:latestBest practices for a course: tag with commit SHAs (not just latest) so the environment never changes under your students mid-semester; use --cache=true for fast rebuilds; keep credentials in CI variables, never in the Dockerfile.
cilogon-jupyterhub-config.yaml shows the production pattern: institutional login, allowlists, no passwords to distribute. Start the CILogon registration weeks before the term.latest?latest moves every time CI runs. Pinning profiles to a SHA means the same image all semester — reproducibility is the whole reason you built a custom image.jhub-values.yaml to add an image profile. How do the changes reach your running hub?kubectl apply on it fails. helm upgrade re-renders the templates with your new values and rolls out only what changed./home/shared folder. What makes that work?Where to go next
- Get your own namespace if you were following along on the training 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 workshop.
- 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, Qualcomm access, 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.
- Pin course images to a commit SHA so the environment never shifts mid-semester.