Server profiles
Profiles are the server sizes users pick from at spawn. Each entry in
jupyterhub.custom.profiles maps directly to a
KubeSpawner profile_list item, so
anything KubeSpawner accepts works without a chart change.
The chart ships two:
| Profile | Slug | Resources |
|---|---|---|
| Small Instance (default) | small-instance | 1 CPU / 2 GB limit, 0.5 CPU / 1 GB guarantee |
| Medium Instance | medium-instance | 4 CPU / 8 GB limit, 2 CPU / 4 GB guarantee |
Set profiles: [] to remove the selector entirely and run in single-instance mode.
Adding a profile
Section titled “Adding a profile”jupyterhub: custom: profiles: - slug: large-instance display_name: "Large Instance" description: "16 CPU / 64 GB RAM — large in-memory datasets." kubespawner_override: image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922 cpu_limit: 16 cpu_guarantee: 8 mem_limit: "64G" mem_guarantee: "32G"slug is a stable identifier independent of the human-facing display_name; omit it and
KubeSpawner slugifies the display name ("Large Instance" → large-instance). Set it
explicitly — the slug is what access: keycloak gating matches on, and renaming a display
name would otherwise silently change it.
default: true marks the pre-selected profile. Exactly one should have it.
cpu_guarantee and mem_guarantee become the pod’s requests; cpu_limit and mem_limit
become its limits. Guarantees drive scheduling, so a guarantee larger than any node can
satisfy leaves the server Pending forever with no message in the UI.
kubespawner_override accepts any KubeSpawner trait — node_selector, image,
extra_resource_limits, tolerations, environment, and the rest.
Image variants
Section titled “Image variants”A profile that needs a different JupyterLab image than the CPU default should declare
image-variant: <name> instead of pinning an image. The chart injects
<singleuser.image.name>-<name>:<singleuser.image.tag>, so the profile follows every pack
update exactly like the shipped CPU profiles. gpu is the only variant published today.
This is the expected way to define a GPU profile:
- slug: gpu-instance display_name: "G4 GPU Instance" image-variant: gpu access: yaml groups: - gpu-access kubespawner_override: # no image needed — the -gpu image is injected automatically node_selector: node.kubernetes.io/instance-type: g4dn.xlarge extra_resource_limits: nvidia.com/gpu: 1Both JupyterLab images are built from the same commit and share the same sha- tag, so
the derived ref (nebari-data-science-pack-jupyterlab-gpu:<tag>) exists on quay.io/nebari
for every release. Nothing in the overlay goes stale
(issue #230).
The access and groups keys above are explained under
Gating profiles by group; getting the pod scheduled onto a GPU
node is covered under GPU profiles.
Like the gating keys, image-variant is stripped (whatever its value) before the profile
reaches KubeSpawner. The hub logs the injected ref at startup:
kubectl -n data-science logs deploy/hub | grep "profiles:.*gpu"Two things override the injection:
- An explicit
kubespawner_override.imagealways wins. Note thatscripts/bump_image_tags.pyonly rewrites the CPU image ref, so a hand-pinned-gpuimage stays frozen across releases — the exact problemimage-variantexists to solve. Prefer the key over pinning. profile_options.image, the per-profile image dropdown described in the next section. KubeSpawner applies the selected choice’skubespawner_overrideafter the profile-level one and replaces rather than merges, so an image choice silently puts the CPU image on the GPU node (while jhub-apps’ Create App still displays the injected GPU image). The shipped CPU profiles carry such an option — do not copy it onto animage-variantprofile. The hub logs a warning if you do.
jupyterhub.custom.image-variants.<name> changes which image gets injected for a variant chart-wide.
Image choices within a profile
Section titled “Image choices within a profile”profile_options adds a second dropdown under the selected profile:
- slug: small-instance display_name: "Small Instance" default: true kubespawner_override: image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922 cpu_limit: 1 mem_limit: "2G" profile_options: image: display_name: Image choices: default: display_name: "nebari-data-science-pack-jupyterlab:sha-16c1922" default: true kubespawner_override: image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922 rlang: display_name: "R" kubespawner_override: image: quay.io/nebari/nebari-data-science-pack-jupyterlab-r:sha-16c1922Do not add profile_options.image to an image-variant profile: the selected choice
replaces the injected image (see Image variants).
Gating profiles by group
Section titled “Gating profiles by group”Each profile can declare an access mode controlling who sees it. This is parity with
classic Nebari.
access | Visible to |
|---|---|
all (or omitted) | everyone |
yaml | users whose Keycloak groups intersect groups, or whose preferred_username is in users |
keycloak | users whose jupyterlab-profiles Keycloak role lists this profile’s slug |
- slug: large-instance display_name: "Large Instance" access: yaml groups: - data-engineering users: - alice kubespawner_override: cpu_limit: 16 mem_limit: "64G"GPU profiles are gated the same way. The complete GPU recipe, including the image, is the example under Image variants.
The access, groups, and users keys are gating-only — they are stripped before the
profile reaches KubeSpawner.
access: keycloak
Section titled “access: keycloak”Moves the allow-list out of the values file and into Keycloak, which is what you want when the people granting access are not the people editing Helm values.
Create a jupyterlab-profiles client role on the hub client with:
- attribute
profiles— the allowed slugs - attribute
component=jupyterhub-profiles
then assign the role to users or groups. The authenticator resolves it at login through the
Keycloak Admin API and stamps the result into auth_state, where the spawner reads it.
Note this requires the hub client’s service account to hold the realm-management view
roles — which is exactly what the Keycloak bootstrap Job
provisions.
Groups come from the token
Section titled “Groups come from the token”Both yaml gating and shared storage read the user’s Keycloak groups from the groups
claim. If that claim is empty, access: yaml profiles are invisible to everyone and shared
directories do not mount.
The usual cause is a missing oidc-group-membership-mapper on the groups client scope —
which is the first thing the bootstrap Job fixes. Check with:
kubectl -n data-science logs deploy/hub | grep -i "profiles:\|groups"GPU profiles
Section titled “GPU profiles”For the image, use image-variant: gpu rather than pinning a -gpu SHA; the full profile is
the example under Image variants. This section covers getting the pod onto
a GPU node.
A GPU profile requests the resource through extra_resource_limits, but scheduling onto a
tainted GPU node group also needs a toleration — whether you must add it yourself depends on
whether the cluster runs the ExtendedResourceToleration admission controller (EKS and GKE
do; AKS and most self-managed clusters do not).
That, plus the fact that tolerations in kubespawner_override replaces rather than
appends, is covered in detail on the GPU profiles page added by
PR #139.
Idle culling
Section titled “Idle culling”Two independent cullers, both on by default.
| Value | Default | Scope | |
|---|---|---|---|
| In-pod | singleuserCuller.* | 15 min | Kernels, terminals, and the server itself — fires even with a browser tab open |
| Hub-level | jupyterhub.cull | 30 min | Servers the hub sees as inactive |
The in-pod culler is the one that actually reclaims resources from users who leave a tab open overnight; the hub-level culler is the backstop. Raising one without the other rarely does what you want — see Values reference.
Storage per user
Section titled “Storage per user”Each user gets two PVCs, both ReadWriteOnce:
| PVC | Size value | Default | Contents |
|---|---|---|---|
claim-{username} | jupyterhub.custom.storage-capacity | 20Gi | Home directory, mounted at /home/jovyan |
nebi-workspaces-{slug} | jupyterhub.custom.workspace-storage-capacity | 20Gi | Nebi (pixi) environments |
Set workspace-storage-class and let storage-capacity follow your home-directory policy.
Pixi environments run 2–5 GiB each, so the workspace PVC fills faster than people expect.
After changing profiles
Section titled “After changing profiles”Profile changes live in the hub ConfigMap, so the hub must restart:
kubectl -n data-science rollout restart deployment/hubRunning servers keep the profile they spawned with. Users see the new list on their next spawn.