Migrate from NGINX to Traefik Ingress on Jetstream2 Kubernetes

kubernetes
jetstream
jupyterhub
Author

Andrea Zonca

Published

May 18, 2026

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-nginx as the ingress controller
  • JupyterHub deployed with HTTPS via cert-manager
  • kubectl, helm, and the openstack CLI installed and configured
  • The KUBECONFIG environment 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.

  1. Install Traefik alongside ingress-nginx
  2. Update the JupyterHub Ingress to use ingressClassName: traefik
  3. Update the cert-manager ClusterIssuer to use Traefik
  4. Update DNS to point to the Traefik load balancer IP
  5. 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-namespace

Wait for the external IP to be assigned (takes about 2 minutes):

kubectl get svc -n traefik traefik -w

Note the Traefik IP:

export TRAEFIK_IP=$(kubectl get svc -n traefik traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo $TRAEFIK_IP

Also 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_IP

At 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 --install against an already-running Traefik does not change the floating IP — the OpenStack cloud controller ignores the new value. You must helm uninstall traefik first, 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.yaml

This 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_IP

There 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.org

Option 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_IP

Clients 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_IP

Verify 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-nginx

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

  1. Install Traefik with kubernetesIngressNGINX.enabled=true and publishService.enabled=false
  2. Switch DNS to the Traefik IP (nginx-class Ingresses still work via Traefik)
  3. Preserve the nginx IngressClass, then uninstall ingress-nginx
  4. Update the JupyterHub Ingress to ingressClassName: traefik
  5. Update the cert-manager ClusterIssuer
  6. (Optional) Re-install Traefik without kubernetesIngressNGINX and delete the nginx IngressClass

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=false

Wait for the external IP:

export TRAEFIK_IP=$(kubectl get svc -n traefik traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo $TRAEFIK_IP

At 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_IP

Wait 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-values flag 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-nginx

The 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 nginx

Note: After disabling kubernetesIngressNGINX, any remaining nginx-class Ingresses will return 404. Make sure all Ingresses have been updated to ingressClassName: traefik before running this step.

Issues and Feedback

Please open an issue on the repository to report any issue or give feedback.