Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
JupyterHub gives teams a central, multiuser environment for running Jupyter notebooks. This article describes how to deploy the infrastructure required to run JupyterHub on Azure Kubernetes Service (AKS) by using Terraform and the official Zero to JupyterHub Helm chart.
Important
This configuration is a sample. Review your organization's networking, identity, security, reliability, and data-protection requirements before using it in production. TLS isn't included in the sample configuration.
Architecture
This configuration creates the following resources:
- An Azure storage account configured with zone-redundant storage (ZRS) and an Azure file share for shared user storage.
- An AKS cluster with a single node pool, Microsoft Entra Workload ID, and an OpenID Connect (OIDC) issuer.
- A Standard SKU public IP address for the JupyterHub proxy
LoadBalancer. The address is created before JupyterHub so it can be used in the OAuth callback URL.
Azure resource group (existing)
├── Azure storage account, ZRS (shared user storage)
│ └── Azure file share: shared (100 GiB)
└── AKS cluster
├── 3 nodes (Standard_D4ds_v6), availability zones 2 and 3
├── Azure CNI networking
├── Microsoft Entra Workload ID and OIDC issuer
└── Public IP address (Standard SKU, in the node resource group)
└── JupyterHub proxy LoadBalancer
The deployment uses the following storage configuration:
- Per-user home directories: Azure Files persistent volume claims (PVCs), provisioned automatically by the JupyterHub Helm chart through the
azurefile-csi-wiStorageClass. - Shared storage: An Azure file share mounted at
/home/jovyan/sharedin all user pods for datasets and course materials.
Prerequisites
Before you begin, ensure you have:
- An Azure subscription with appropriate permissions.
- An existing resource group. The Terraform configuration references the resource group and doesn't create or delete it.
- Terraform version 1.0 or later.
- Azure CLI version 2.56 or later.
- kubectl version 1.28 or later.
- Helm version 3.5 or later.
- just, if you want to use the included
Justfilerecipes. - An authenticated Azure CLI session. Run
az login, or authenticate by using a service principal.
Deploy by using the Justfile
Run the following commands from the directory that contains the Terraform configuration and Justfile:
# Initialize, validate, format, and plan the Terraform configuration.
just tf-workflow
# Deploy the infrastructure.
just tf-apply
# Get the AKS credentials.
just tf-get-aks-creds
# Display the OAuth callback URL required by the GitHub App.
terraform output jupyterhub_oauth_callback_url
# Deploy the namespace, storage resources, and JupyterHub.
GITHUB_CLIENT_ID="xxx" GITHUB_CLIENT_SECRET="yyy" just deploy-all
# Display the Terraform outputs.
just tf-output
Run just in the configuration directory to list all available recipes. Terraform recipes use the tf-* prefix. The Justfile also includes Kubernetes deployment recipes.
Configure GitHub authentication
JupyterHub uses a GitHub App for OAuth authentication. You can create the app automatically or manually.
Create the GitHub App automatically
# Generate the GitHub App creation page.
just create-github-app
# After GitHub redirects you with a code, convert the app.
just convert-github-app <CODE>
# Edit .env and set GITHUB_ADMIN_USERS and GITHUB_ALLOWED_USERS.
# Then deploy the Kubernetes resources and JupyterHub.
just deploy-all
Open github-app-create.html in a browser and select Create GitHub App before you run just convert-github-app.
Create the GitHub App manually
Go to GitHub Developer settings, and create a GitHub App.
For Application name, enter
JupyterHub AKSor another name.For Homepage URL, enter
http://<PUBLIC_IP>.For Callback URL, enter
http://<PUBLIC_IP>/hub/oauth_callback.Record the client ID and generate a client secret.
Save the values in the
.envfile:GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=yyy GITHUB_ADMIN_USERS=your-github-username GITHUB_ALLOWED_USERS=your-github-usernameDeploy JupyterHub:
just deploy-all
Caution
Don't commit the .env file, client secret, Terraform state, or kubeconfig to source control. Store secrets in an approved secret-management system.
Manage the GitHub App
Use these GitHub pages to manage the app:
- GitHub Developer settings to edit the app.
- GitHub App installations to manage the accounts where the app is installed.
If the public IP address changes, get the new callback URL and update it in the app settings:
terraform output jupyterhub_oauth_callback_url
Access JupyterHub
Go to http://<PUBLIC_IP>, and then select Sign in with GitHub.
Deploy manually
Initialize Terraform
terraform init
This command downloads the required providers, including AzureRM 4.x and Random 3.x.
Review the deployment plan
terraform plan
The included terraform.tfvars file contains sample values that allow Terraform to create a plan.
Customize the variables
Edit terraform.tfvars for your environment:
resource_group_name = "260300-ai-4"
location = "eastus2"
aks_cluster_name = "jhub-aks-260300-ai-4"
public_ip_name = "jhub-ip-260300ai4"
Deploy the infrastructure
terraform apply
Review the plan, and then enter yes to continue.
Get the AKS credentials
az aks get-credentials \
--resource-group $(terraform output -raw resource_group_name) \
--name $(terraform output -raw aks_cluster_name) \
--overwrite-existing
Deploy the Kubernetes resources
GITHUB_CLIENT_ID="xxx" GITHUB_CLIENT_SECRET="yyy" just deploy-all
Configuration file structure
stateful-workloads-jupyterhub-terraform/
├── provider.tf
├── variables.tf
├── main.tf
├── kubernetes.tf
├── outputs.tf
├── terraform.tfvars
├── .gitignore
├── validate.sh
├── Justfile
├── k8s/
│ ├── namespace.yaml
│ ├── shared-storage.yaml.tpl
│ ├── storageclass-wi.yaml.tpl
│ ├── service-account.yaml.tpl
│ └── jupyterhub-values.yaml.tpl
├── PLAN.md
└── README.md
Understand the storage configuration
Shared storage
All users share an Azure Files mount at /home/jovyan/shared. The mount uses ReadWriteMany access and is intended for shared datasets, course materials, and notebooks. Don't use the shared directory for sensitive per-user data.
Per-user storage
Each user gets a dedicated home-directory PVC with the following configuration:
- The JupyterHub Helm chart provisions the PVC automatically.
- The PVC uses the
azurefile-csi-wiStorageClass and Azure Files SMB. ReadWriteManyaccess supports named servers and multiple pods per user.- The default capacity is 10 GiB per user and can be changed in the Helm values.
Note
Azure Files SMB mounts don't support POSIX file permissions in the same way as a native Linux file system. The configuration sets JUPYTER_ALLOW_INSECURE_WRITES=1 to prevent Jupyter Server from failing when it checks permissions for runtime files.
Configure workload identity for Azure Files
The configuration uses Microsoft Entra Workload ID so that storage account keys don't need to be stored in Kubernetes secrets. AKS Standard clusters must have workload identity and the OIDC issuer enabled before pods can use workload identity. For more information, see Use Microsoft Entra Workload ID with AKS.
The repository supports two SMB authentication modes. Token-based mounting is the default.
Token-based SMB mount
- The CSI driver authenticates the SMB mount by using the workload identity token and Kerberos (
sec=krb5). - The cluster doesn't retrieve the storage account key.
- The StorageClass sets
mountWithWorkloadIdentityToken: "true". - The configuration installs Azure Files CSI driver version 1.35.2 and disables the AKS-managed Azure Files CSI driver for this sample.
- The storage account must have Microsoft Entra Kerberos enabled for Azure Files.
- The workload identity requires the Storage File Data SMB MI Admin role.
Key-retrieval SMB mount
- The CSI driver uses the workload identity to call
listKeysat mount time, and then mounts the share by using the storage account key. - The key is held in driver memory and isn't persisted to a Kubernetes secret.
- The workload identity requires the Storage Account Contributor role.
- Use this mode if your CSI driver or environment doesn't support token-based SMB mounting.
Select an authentication mode
| Configuration | Effect |
|---|---|
STORAGE_AUTH_MODE=token |
Default. Adds mountWithWorkloadIdentityToken: "true" to the StorageClass. |
STORAGE_AUTH_MODE=key |
Omits the parameter and uses key retrieval at mount time. |
Set the mode in .env:
STORAGE_AUTH_MODE=token
# STORAGE_AUTH_MODE=key
Or specify it when you create the StorageClass:
STORAGE_AUTH_MODE=key just create-storageclass
Note
StorageClass parameters are immutable. The create-storageclass recipe deletes and recreates the StorageClass when you switch modes. Existing persistent volumes keep their original mount parameters.
Required Azure roles
| Identity | Role | Purpose |
|---|---|---|
| AKS kubelet identity | Storage Account Contributor | Allows the CSI controller to create and delete file shares during provisioning. |
| Workload managed identity | Storage Account Contributor | Supports dynamic provisioning and key-retrieval mounting. |
| Workload managed identity | Storage File Data SMB MI Admin | Supports the default token-based SMB mount. |
Important
The CSI controller uses the AKS kubelet identity for provisioning. The clientID value in the StorageClass controls mount-time authentication. Assign the required roles to both identities at the appropriate scope.
For the upstream implementation details, see Mount Azure Files by using workload identity.
Configure the reclaim policy
The azurefile-csi-wi StorageClass uses Retain by default. When a PVC is deleted, the persistent volume remains in the Released state and the underlying Azure file share isn't deleted.
Set a different policy in .env:
STORAGE_RECLAIM_POLICY=Retain
# STORAGE_RECLAIM_POLICY=Delete
Or specify it when you create the StorageClass:
STORAGE_RECLAIM_POLICY=Delete just create-storageclass
| Policy | When to use | Trade-off |
|---|---|---|
| Retain | Production or teaching environments where user data must survive accidental PVC deletion or Helm removal. | Orphaned pvc-<uuid> shares must be managed manually. |
| Delete | Ephemeral development, test, or continuous integration environments. | Deleting the PVC permanently deletes its dynamically provisioned share. |
The reclaim policy is immutable on an existing StorageClass. The create-storageclass recipe deletes and recreates the StorageClass when you switch policies. Existing persistent volumes retain the policy that was set when they were provisioned. To update an individual persistent volume, run:
kubectl patch pv <name> -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
The Terraform-managed shared file share is independent of this setting because Terraform manages its lifecycle.
Named servers and idle culling
Users can run up to three notebook servers at the same time. Named servers share the user's Azure Files home-directory PVC and can be managed from the JupyterHub control panel. Change the limit through named_server_limit_per_user in the Helm values.
Inactive notebook servers are stopped after one hour to reduce resource consumption.
View deployment outputs
# Display all outputs.
terraform output
# Display specific outputs.
terraform output public_ip_address
terraform output jupyterhub_oauth_callback_url
# Write the sensitive kubeconfig output.
terraform output -raw aks_kube_config > ~/.kube/config
Important outputs include:
public_ip_address: The IP address used to access JupyterHub.jupyterhub_url: The complete JupyterHub URL.jupyterhub_oauth_callback_url: The callback URL used to register the GitHub App.storage_account_name: The storage account that contains the shared Azure file share.aks_cluster_name: The AKS cluster name used forkubectlaccess.
Configure TLS
This configuration doesn't include TLS. For HTTPS, configure a domain name and use the AKS application routing add-on with Azure DNS:
az aks approuting enable \
--name <cluster-name> \
--resource-group <resource-group>
Important
Don't use plain HTTP for a production deployment or transmit OAuth credentials over an unencrypted connection. Configure HTTPS and update the GitHub App homepage and callback URLs to use https://.
Clean up resources
Remove the Kubernetes resources before destroying the Terraform-managed infrastructure:
just teardown
terraform destroy
Warning
Destroying the deployment can permanently delete resources and user data. Review retained persistent volumes, Azure file shares, and Terraform's planned deletions before you continue.
Troubleshoot
Terraform provider initialization fails
Upgrade the initialized providers:
terraform init -upgrade
The subscription has insufficient VM quota
Use the repository's quota-check recipe:
cd ../az && just az-quota-check
JupyterHub pods don't start
just pods
just events
just logs <pod-name>
A server doesn't respond within 30 seconds
Azure Files PVC provisioning can take longer during the first sign-in. The sample sets the spawn timeout to 120 seconds. If the deployment still times out, increase KubeSpawner.http_timeout and KubeSpawner.start_timeout in the Helm values.
A notebook crashes with a secure-file permissions error
Azure Files SMB doesn't provide native POSIX permission semantics. Jupyter Server might report an error similar to:
RuntimeError: Permissions assignment failed for secure file:
'/home/jovyan/.local/share/jupyter/runtime/jpserver-7.json'.
Got '0o677' instead of '0o0600'.
The sample Helm values set JUPYTER_ALLOW_INSECURE_WRITES to 1. If the error occurs, confirm that the environment variable is present in the single-user container.
GitHub reports an OAuth callback error
Get the configured callback URL and verify that it exactly matches the callback URL in the GitHub App settings:
terraform output jupyterhub_oauth_callback_url
The VM size isn't available in the selected zones
Verify the availability of the VM size in the selected region:
az vm list-skus \
--location eastus2 \
--size Standard_D4ds_v6 \
--output table