Deploy Kubeflow with Password, Ingress and TLS

Background

Use the Azure CLI and the main.bicep template in this repository to deploy an Azure Kubernetes Service (AKS) cluster, then install Kubeflow on it. Kubeflow is installed from a pinned community distribution release on a hostname you choose, served over publicly trusted HTTPS, with a generated Dex password that can be rotated.

Prerequisites

Install the following into that shell:

  • Azure CLI
  • just, which runs every deployment step in this repository
  • Bicep 0.46.1 exactly, the version versions.env records and just validate-static checks
  • Kustomize v5.8.1 exactly, because later versions can remove Kustomize APIs used by the pinned Kubeflow release
  • Kubectl v1.35, v1.36 or v1.37, within one minor version of the 1.36 API server this deployment creates
  • Kubelogin, the exec plugin that signs you in to the cluster
  • Go 1.26.0 or greater. tools/password/go.mod requests the go1.26.6 toolchain, which Go downloads on demand; install 1.26.6 itself if GOTOOLCHAIN is set to local
  • git
  • Python 3.11 or later, with venv and pip. just e2e builds a virtual environment under .cache/ and runs the release gate in it. On Debian and Ubuntu venv is a separate python3-venv package
  • jq, used to find pods that started before Istio could give them a sidecar
  • OpenSSL, used to read the serving certificate’s serial number
  • sed, used to render the Dex configuration
  • curl, tar, sha256sum, base64, tr, grep and awk, used to fetch and verify the Kubeflow release and to render its secrets
  • Network access to codeload.github.com, the Go module proxy, and PyPI

The deployment checks the Kustomize version and refuses to run on a mismatch, so install it with Go rather than a package manager:

go install sigs.k8s.io/kustomize/kustomize/v5@v5.8.1
kustomize version

The Azure CLI ships and updates kubectl and kubelogin for you. If either is missing, install or update both with:

az aks install-cli

For a deployment identity with only the permissions this deployment needs, see the least-privilege custom role.

Deploy AKS

Deploy the cluster with the main.bicep template in this repository.

The cluster uses Microsoft Entra ID with Azure RBAC for Kubernetes authorization, so kubectl access follows from a role assignment rather than from a shared credential. AKS still issues a cluster-local admin credential to callers holding listClusterAdminCredential, which bypasses Entra entirely; the least-privilege custom role deliberately withholds it.

Sign in to the Azure CLI, if you are not signed in already. An existing non-interactive session, such as a service principal in a pipeline, is fine and needs nothing here.

az login

Clone this repository.

git clone https://github.com/Azure/kubeflow-aks.git
cd kubeflow-aks

Set up your environment variables. Every recipe below reads these.

export RESOURCE_GROUP=kubeflow
export AKS_NAME=kubeflow-aks
export LOCATION=eastus
export SIGNEDINUSER=$(az ad signed-in-user show --query id --out tsv)

SIGNEDINUSER is the object ID of the signed-in user. The deployment grants it the Azure Kubernetes Service RBAC Cluster Admin role on the cluster, which kubectl needs because the cluster authenticates with Microsoft Entra ID.

LOCATION selects the region for the resource group created below. The cluster inherits the region from the resource group, so a group that already exists keeps its own region regardless of this value.

Create the resource group, if it does not exist already. Against a group that exists this is a no-op that returns it.

az group create -n $RESOURCE_GROUP -l $LOCATION

Create the cluster. just validate previews the same deployment without changing anything, after running just validate-static, which needs neither an Azure account nor a cluster and checks the pinned tool versions, the Go password tool, the release gate, and the rendered manifests.

just deploy-aks

Connect to the cluster

After the cluster is created, you can connect to it using the Azure CLI. The following command retrieves the credentials for your AKS cluster and configures kubectl to use them.

just credentials

Verify connectivity to the cluster. This should return a list of nodes.

kubectl get nodes

Install Kubeflow

Run the deployment from the repository root, with the same environment variables exported above still set. No hostname needs choosing: the Bicep deployment generated one, and Kubeflow is served on it over publicly trusted HTTPS.

just deploy-kubeflow

The hostname is kubeflow-<unique>.<location>.cloudapp.azure.com, derived from the resource group, the cluster name and the location.

Wait for the deployment to settle, then check it:

just wait-ready
just e2e

just wait-ready waits for every pod, for the TLS certificate to be issued, and for Dex and OAuth2 Proxy to roll out. It also fails a pod that is Ready but has no Istio sidecar, which readiness alone cannot detect and which leaves large parts of the platform returning 503 through the ingress.

just e2e then exercises the deployment the way a user does. It reads the hostname from the certificate rather than taking one from the environment, signs in to Dex over publicly trusted HTTPS, loads the dashboard, creates a Notebook, waits for it to become ready, lists it through the Jupyter Web App API, opens JupyterLab, and deletes it again. It prints one PASS line per check and verifies certificates normally throughout, with no way to turn that off. It asks for the Dex password, or reads it from DEX_PASSWORD:

DEX_PASSWORD='the password just deploy-kubeflow printed' just e2e

It then forces the certificate to be reissued, to prove that ACME renewal still reaches the cluster through Istio. Nothing else can prove that: a renewal that has quietly stopped working looks identical to one that works until the certificate expires.

Open the printed https:// URL and sign in as user@example.com.

Choosing the Azure hostname

To pick the Azure DNS label rather than accept the generated one, set DNS_LABEL before deploying. It has to be unique within the region.

export DNS_LABEL=my-unique-kubeflow-label

just deploy-kubeflow

The hostname becomes $DNS_LABEL.$LOCATION.cloudapp.azure.com.

Serving Kubeflow on your own domain

To serve Kubeflow on a domain you control, set DOMAIN to a lower-case FQDN. It becomes the certificate and login hostname, and DNS_LABEL selects the Azure name you point it at.

export DOMAIN=kubeflow.example.com

just deploy-kubeflow

Unlike the other two, this path does not complete in one command. just deploy-kubeflow stops and prints the unproxied DNS record to create. Create it, let it resolve publicly, then continue:

just wait-ready
just e2e

The record must send /.well-known/acme-challenge/ on port 80 straight to the Istio ingress. Do not put it behind a proxy or provider-side forced HTTPS: Let’s Encrypt renews a 90-day certificate after roughly 60 days, so an interception added later can leave a working deployment unable to renew. That path is the only one exempt from Kubeflow’s OAuth2 and JWT checks; all other unauthenticated HTTP traffic is denied.

Rotate the Dex password

just configure-dex

This generates a new password and cost-12 bcrypt hash, validates the hash before using it, replaces the dex-passwords Secret, restarts Dex, refreshes the RequestAuthentication JWKS URI so Istio picks up the new signing keys, and waits for the Dex and oauth2-proxy rollouts. Each run invalidates the previous password.

Clean up

Remove everything the deployment created, keeping the resource group and the role assignments scoped to it:

just group-empty

This deploys an empty template in Complete mode, which deletes every resource in the group. It prints the group it is about to empty and waits ten seconds first.

Deleting the resource group itself also works, if you created it. It is worth knowing what that costs: deleting a group removes every role assignment scoped to it along with the resources. Where an administrator created the group and granted access to it, including through the least-privilege custom role, deleting the group destroys that grant. The custom role deliberately excludes resource-group deletion for this reason, so empty the group instead and leave removing it to whoever created it.