Skip to content

Server profiles

Updated 7 min read

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:

ProfileSlugResources
Small Instance (default)small-instance1 CPU / 2 GB limit, 0.5 CPU / 1 GB guarantee
Medium Instancemedium-instance4 CPU / 8 GB limit, 2 CPU / 4 GB guarantee

Set profiles: [] to remove the selector entirely and run in single-instance mode.

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.

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: 1

Both 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:

Terminal window
kubectl -n data-science logs deploy/hub | grep "profiles:.*gpu"

Two things override the injection:

  • An explicit kubespawner_override.image always wins. Note that scripts/bump_image_tags.py only rewrites the CPU image ref, so a hand-pinned -gpu image stays frozen across releases — the exact problem image-variant exists 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’s kubespawner_override after 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 an image-variant profile. The hub logs a warning if you do.

jupyterhub.custom.image-variants.<name> changes which image gets injected for a variant chart-wide.

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-16c1922

Do not add profile_options.image to an image-variant profile: the selected choice replaces the injected image (see Image variants).

Each profile can declare an access mode controlling who sees it. This is parity with classic Nebari.

accessVisible to
all (or omitted)everyone
yamlusers whose Keycloak groups intersect groups, or whose preferred_username is in users
keycloakusers 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.

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.

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:

Terminal window
kubectl -n data-science logs deploy/hub | grep -i "profiles:\|groups"

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.

Two independent cullers, both on by default.

ValueDefaultScope
In-podsingleuserCuller.*15 minKernels, terminals, and the server itself — fires even with a browser tab open
Hub-leveljupyterhub.cull30 minServers 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.

Each user gets two PVCs, both ReadWriteOnce:

PVCSize valueDefaultContents
claim-{username}jupyterhub.custom.storage-capacity20GiHome directory, mounted at /home/jovyan
nebi-workspaces-{slug}jupyterhub.custom.workspace-storage-capacity20GiNebi (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.

Profile changes live in the hub ConfigMap, so the hub must restart:

Terminal window
kubectl -n data-science rollout restart deployment/hub

Running servers keep the profile they spawned with. Users see the new list on their next spawn.