ingress-nginx has been retired and will no longer receive updates. This guide walks through migrating an existing Jetstream2 Kubernetes cluster from ingress-nginx to Traefik, with minimal downtime.
This guide is for existing clusters already running JupyterHub with ingress-nginx. For new clusters, see the 4-post tutorial series.
This guide is based on Ana V. Espinoza’s migration instructions and Traefik’s official migration documentation, adapted and live-tested on Jetstream2.
Prerequisites
- An existing Jetstream2 Kubernetes cluster running
ingress-nginxas the ingress controller - JupyterHub deployed with HTTPS via cert-manager
kubectl,helm, and theopenstackCLI installed and configured- The
KUBECONFIGenvironment variable set for your cluster
Overview
The migration follows a side-by-side strategy: install Traefik alongside nginx, switch the Ingress class, update DNS, and then remove nginx.
Simple approach (brief downtime)
This is the simplest method. After switching the JupyterHub Ingress to ingressClassName: traefik, nginx can no longer serve it, so there is a brief downtime window while DNS propagates.
- Install Traefik alongside
ingress-nginx - Update the JupyterHub Ingress to use
ingressClassName: traefik - Update the cert-manager ClusterIssuer to use Traefik
- Update DNS to point to the Traefik load balancer IP
- Remove
ingress-nginx
Minimal-downtime approach
If uptime is critical, use Traefik’s kubernetesIngressNGINX provider, which makes Traefik serve existing nginx-class Ingresses without any changes. This allows you to switch DNS to Traefik first, verify everything works, and only then update the Ingress class and remove nginx. See the Minimal-Downtime Migration section below.
Step 1: Install Traefik
Install the Traefik Helm chart. This creates a second load balancer alongside the existing nginx one:
helm repo add traefik https://traefik.github.io/charts
helm repo update
helm upgrade --install traefik traefik/traefik \
--namespace traefik --create-namespaceWait for the external IP to be assigned (takes about 2 minutes):
kubectl get svc -n traefik traefik -wNote the Traefik IP:
export TRAEFIK_IP=$(kubectl get svc -n traefik traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo $TRAEFIK_IPAlso note the existing nginx IP:
export NGINX_IP=$(kubectl get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo $NGINX_IPAt this point, both ingress controllers are running. Nginx continues to serve traffic, while Traefik is idle.
Retain the existing IP (optional)
By default, Traefik provisions a new load balancer with a new floating IP, so DNS must be repointed (Step 4). If you want Traefik to take over the existing nginx IP instead, install it with service.spec.loadBalancerIP set to the nginx IP:
helm upgrade --install traefik traefik/traefik \
--namespace traefik --create-namespace \
--set "service.spec.loadBalancerIP=$NGINX_IP"Important — this flag only works on a fresh install: On Jetstream2, re-running
helm upgrade --installagainst an already-running Traefik does not change the floating IP — the OpenStack cloud controller ignores the new value. You musthelm uninstall traefikfirst, then reinstall with the flag. (Discovered by Ana V. Espinoza during live testing — see the PR #94 discussion.)
If you keep the existing IP, you can skip the DNS switch in Step 4 entirely, since the DNS record already points to that IP. The trade-off is a brief window where the new Traefik LB is being created while the old nginx LB is being torn down.
Step 2: Update the JupyterHub Ingress
Update secrets.yaml to use ingressClassName: traefik instead of the deprecated kubernetes.io/ingress.class: "nginx" annotation. If your secrets.yaml still uses the annotation format, change it:
Before:
ingress:
enabled: true
annotations:
kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt"After:
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: "letsencrypt"Then upgrade JupyterHub:
helm upgrade jhub jupyterhub/jupyterhub \
--namespace jhub --version 4.3.3 \
--values config_standard_storage.yaml --values secrets.yamlThis immediately changes the Ingress class from nginx to traefik. From this point:
- Traffic through the Traefik load balancer reaches JupyterHub ✅
- Traffic through the nginx load balancer gets a 404, because nginx no longer watches this Ingress ⚠️
This is why the DNS switch in Step 4 is important.
Note on nginx-specific annotations: If you use nginx-specific annotations like
nginx.ingress.kubernetes.io/proxy-body-size, replace them with the Traefik equivalent. For example, to allow large file uploads:# nginx annotation (unsupported by Traefik): # nginx.ingress.kubernetes.io/proxy-body-size: "500m" # Traefik equivalent: traefik.ingress.kubernetes.io/middlewares.limit.buffering.maxRequestBodyBytes: "500000000"
Step 3: Update the cert-manager ClusterIssuer
Update the ClusterIssuer to use Traefik for HTTP01 challenges. Edit https_cluster_issuer.yml and change the class from nginx to traefik, then apply:
cd jupyterhub-deploy-kubernetes-jetstream/setup_https
sed 's/YOUREMAIL/your@email.edu/' https_cluster_issuer.yml | kubectl apply -f -This ensures that future certificate renewals use Traefik for the HTTP01 challenge.
Step 4: Update DNS
Option A: Direct cutover (brief downtime)
Update the DNS record to point to the Traefik IP:
export PROJ="xxx000000"
export K8S_CLUSTER_NAME=k8s
openstack recordset set \
$PROJ.projects.jetstream-cloud.org. \
$K8S_CLUSTER_NAME.$PROJ.projects.jetstream-cloud.org. \
--record $TRAEFIK_IPThere will be a brief downtime while DNS caches update. You can verify when the change has propagated:
watch nslookup $K8S_CLUSTER_NAME.$PROJ.projects.jetstream-cloud.orgOption B: Dual-A-record (zero downtime)
Add both IPs to the DNS record so that traffic can reach either ingress controller:
openstack recordset set \
$PROJ.projects.jetstream-cloud.org. \
$K8S_CLUSTER_NAME.$PROJ.projects.jetstream-cloud.org. \
--record $NGINX_IP --record $TRAEFIK_IPClients that resolve to the Traefik IP get JupyterHub directly. Clients that still resolve to the nginx IP get a 404, but DNS round-robin means roughly half of requests succeed. After DNS fully propagates (wait a few minutes), switch to the Traefik-only record:
openstack recordset set \
$PROJ.projects.jetstream-cloud.org. \
$K8S_CLUSTER_NAME.$PROJ.projects.jetstream-cloud.org. \
--record $TRAEFIK_IPVerify connectivity
Test both load balancers directly to confirm Traefik is serving JupyterHub and nginx is no longer needed:
export FQDN=$K8S_CLUSTER_NAME.$PROJ.projects.jetstream-cloud.org
# Test HTTPS via Traefik
curl --connect-to "${FQDN}:443:${TRAEFIK_IP}:443" "https://${FQDN}" -v -D - 2>&1 | grep "HTTP/"
# Test HTTPS via nginx (should return 404)
curl --connect-to "${FQDN}:443:${NGINX_IP}:443" "https://${FQDN}" -v -D - 2>&1 | grep "HTTP/"Step 5: Remove ingress-nginx
Once DNS points to the Traefik IP and JupyterHub is accessible, uninstall ingress-nginx:
helm uninstall -n ingress-nginx ingress-nginx
kubectl delete ns ingress-nginxThis removes the nginx pods, the nginx load balancer, and its namespace. The associated OpenStack load balancer is also cleaned up automatically.
Verify the Migration
Confirm that everything works:
# Check that only Traefik is running as an ingress controller
kubectl get ns | grep -E "traefik|ingress"
# Check the Ingress class
kubectl get ingress -n jhub
# Check the certificate
kubectl get certificate -n jhub
# Test HTTPS access
curl -s -o /dev/null -w "%{http_code}\n" https://$K8S_CLUSTER_NAME.$PROJ.projects.jetstream-cloud.org/You should see: - Only the traefik namespace (no ingress-nginx) - Ingress with CLASS: traefik - Certificate READY: True - HTTPS returns 302
Summary of Changes
| Component | Before | After |
|---|---|---|
| Ingress controller | ingress-nginx |
traefik |
| Ingress class | nginx |
traefik |
| Secret annotation | kubernetes.io/ingress.class: "nginx" |
ingressClassName: traefik |
| cert-manager solver | class: nginx |
class: traefik |
| Load balancer IP | $NGINX_IP |
$TRAEFIK_IP |
Minimal-Downtime Migration
If uptime is critical, use Traefik’s kubernetesIngressNGINX provider to serve existing nginx-class Ingresses through Traefik without any changes. This eliminates the downtime window between switching the Ingress class and updating DNS. The approach was suggested by Ana V. Espinoza and follows Traefik’s official migration guide.
The step order changes from the simple approach:
- Install Traefik with
kubernetesIngressNGINX.enabled=trueandpublishService.enabled=false - Switch DNS to the Traefik IP (nginx-class Ingresses still work via Traefik)
- Preserve the
nginxIngressClass, then uninstallingress-nginx - Update the JupyterHub Ingress to
ingressClassName: traefik - Update the cert-manager ClusterIssuer
- (Optional) Re-install Traefik without
kubernetesIngressNGINXand delete thenginxIngressClass
Step 1: Install Traefik with NGINX compatibility
Install Traefik with the kubernetesIngressNGINX provider enabled. This provider makes Traefik watch for nginx-class Ingresses and serve them natively, translating NGINX annotations into Traefik configuration automatically.
Disable publishService to prevent Traefik from overwriting the Ingress status with its own IP, which would cause a race condition with nginx:
helm repo add traefik https://traefik.github.io/charts
helm repo update
helm upgrade --install traefik traefik/traefik \
--namespace traefik --create-namespace \
--set providers.kubernetesIngressNGINX.enabled=true \
--set providers.kubernetesIngressNGINX.publishService.enabled=falseWait for the external IP:
export TRAEFIK_IP=$(kubectl get svc -n traefik traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo $TRAEFIK_IPAt this point, both nginx and Traefik serve your existing Ingresses. Traffic is still flowing through nginx since DNS points to the nginx IP.
Step 2: Switch DNS to Traefik
Before changing DNS, verify that Traefik correctly serves your Ingresses by testing directly against the Traefik IP:
export FQDN=$K8S_CLUSTER_NAME.$PROJ.projects.jetstream-cloud.org
# Test HTTPS via Traefik (should return 302)
curl -s -o /dev/null -w "%{http_code}\n" \
--connect-to "${FQDN}:443:${TRAEFIK_IP}:443" "https://${FQDN}"If that returns 302, switch DNS to point to the Traefik IP:
openstack recordset set \
$PROJ.projects.jetstream-cloud.org. \
$K8S_CLUSTER_NAME.$PROJ.projects.jetstream-cloud.org. \
--record $TRAEFIK_IPWait for DNS propagation, then verify JupyterHub is accessible via the domain name. There is zero downtime because Traefik serves the nginx-class Ingresses identically.
Step 3: Remove ingress-nginx
Before uninstalling nginx, preserve the nginx IngressClass with a helm.sh/resource-policy: keep annotation. Traefik’s kubernetesIngressNGINX provider needs this IngressClass to discover the Ingresses:
helm upgrade ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx \
--reuse-values \
--set-json 'controller.ingressClassResource.annotations={"helm.sh/resource-policy":"keep"}'Important: The
--reuse-valuesflag preserves your existing nginx configuration. Without it, Helm resets everything to defaults.
Then uninstall nginx:
helm uninstall -n ingress-nginx ingress-nginx
kubectl delete ns ingress-nginxThe nginx IngressClass is preserved by the annotation. Verify that JupyterHub still works — Traefik continues serving the nginx-class Ingresses through the kubernetesIngressNGINX provider.
Step 4: Update the JupyterHub Ingress
Now update secrets.yaml to use ingressClassName: traefik and upgrade JupyterHub (same as Step 2 in the simple approach above).
Step 5: Update the cert-manager ClusterIssuer
Update the ClusterIssuer to use class: traefik (same as Step 3 in the simple approach above).
Step 6: Clean up (optional)
Once all Ingresses have been updated to ingressClassName: traefik, the kubernetesIngressNGINX provider is no longer needed. Re-install Traefik without it and delete the preserved nginx IngressClass:
helm upgrade --install traefik traefik/traefik \
--namespace traefik \
--reuse-values \
--set providers.kubernetesIngressNGINX.enabled=false
kubectl delete ingressclass nginxNote: After disabling
kubernetesIngressNGINX, any remainingnginx-class Ingresses will return 404. Make sure all Ingresses have been updated toingressClassName: traefikbefore running this step.
Issues and Feedback
Please open an issue on the repository to report any issue or give feedback.