Ingress NGINX Migration Guide (3 of 3): Cut Over One Ingress at a Time with Zero Downtime

A real zero-downtime migration is not a moment; it is an invariant. At every step, including the one you are about to take, there must be a working configuration you can revert to in seconds. Not a backup. Not a recovery plan. A live, running, traffic-serving configuration, one kubectl edit away.
That is what this post is about. The combination of Traefik v3.7's native Ingress NGINX annotation support and its priority-based routing model lets you keep that invariant from the first packet to the last. You move Ingresses one at a time. You always have somewhere to fall back to. And when the last Ingress is migrated, you find out you have been in production on Traefik for weeks already.
This is the third and final post of the series, published alongside the Traefik v3.7 GA:
- Audit: inventory your annotations and translate the NGINX ConfigMap.
- Install: deploy Traefik with Helm next to your existing NGINX controller.
- Progressive migration (this post): cut over route by route, with zero downtime.
The official Traefik migration guide documents a blue-green approach. This post takes a different path, one designed for teams that cannot freeze a cluster for an hour, let alone a weekend.
The Catch-All is the Invariant
Every progressive migration in this series rests on one mechanism: a Traefik catch-all router sitting in front of Ingress NGINX, with a priority lower than any real Ingress. Traefik forwards any request for which it has no matching rule to Ingress NGINX unchanged.
That router is the safety net. As long as it is in place, every request that does not match a migrated Ingress still flows through Ingress NGINX exactly as it did before. You can migrate one Ingress, ten, or zero, and the rest of your production traffic does not know the difference.
There is no big-bang. There is no maintenance window. There is no "migration day". There is a sequence of tiny, individually reversible changes, each protected by the same fallback.
The rest of this post walks through the two operational moves that make this work: putting Traefik in front of Ingress NGINX (Step 1), and migrating Ingresses one by one (Step 2). They both rely on the catch-all routes, as well as on the audits and installations you have already carried out.
Initial Situation
Coming out of the second post:
- Ingress NGINX serves all external traffic via a LoadBalancer (or NodePort) service.
- An
IngressClassnamednginx, with controllerk8s.io/ingress-nginx, tells the cluster which Ingress resources Ingress NGINX should handle. - Traefik is deployed in the cluster but unreachable from outside. It is configured to accept both Ingress resources (with Ingress NGINX annotations) on the
traefik-nginxIngressClass, and Traefik CRDs (IngressRoute,IngressRouteTCP).
Current traffic flow:

For the rest of this post, we will work against a representative production-style Ingress that uses the features most likely to trip up a naive migration: snippets, header manipulation, and SSL redirects.
# IngressClass
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: nginx
spec:
controller: k8s.io/ingress-nginx
# Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-with-nginx-annotation
annotations:
# SNIPPETS
nginx.ingress.kubernetes.io/server-snippet: |
location = /exact {
return 200 "exact-match";
}
nginx.ingress.kubernetes.io/configuration-snippet: |
# Add Headers on the response
add_header X-Method $request_method;
set $my_var "hello";
add_header X-My-Var $my_var;
set $combined "$request_method-$request_uri";
add_header X-Combined $combined;
# Add/Set Headers on the request
proxy_set_header X-Backend-Uri $request_uri;
more_set_input_headers "X-Custom-Input:input-value";
# SSL Redirect
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: whoami.docker.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
number: 80
tls:
- hosts:
- whoami.docker.localhost
secretName: external-certs
For Ingress NGINX to honor the snippets on this Ingress, the controller ConfigMap must allow them:
# NGINX Configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-configuration
data:
use-forwarded-headers: "true"
allow-snippet-annotations: "true"
annotations-risk-level: Critical
Step 1: Put Traefik in Front of Ingress NGINX
This first step is to put Traefik on the traffic path, while Ingress NGINX keeps handling every request unchanged. Once this step is complete, the invariant is established: a single one-line change to the LoadBalancer selector reverts the entire cluster to the pre-Traefik state.
1. Check Traefik Configuration
In the previous blog post, we configured Traefik with the kubernetesIngressNGINX provider pointed at the traefik-nginx IngressClass, the traefik-ingress-controller controller, and the kubernetesCRD provider enabled, so we can define IngressRoute and IngressRouteTCP resources in the next step.
Before we go further, double-check the Traefik deployment is wired the way we expect:
$kubectl describe deployment/traefik
…
Pod Template:
Labels: app.kubernetes.io/name=traefik
Service Account: traefik-ingress-controller
Containers:
traefik:
Image: traefik:v3.7.0
Ports: 80/TCP, 8080/TCP, 443/TCP
Host Ports: 0/TCP, 0/TCP, 0/TCP
Args:
--api.insecure
--entrypoints.traefik.address=:8080
--entrypoints.web.address=:80
--entrypoints.websecure.address=:443
--entryPoints.websecure.http.tls=true
--providers.kubernetesIngressNGINX.ingressClass=traefik-nginx
--providers.kubernetesIngressNGINX.ingressClassByName=true
--providers.kubernetesIngressNGINX.controllerClass="traefik.io/ingress-controller"
--providers.kubernetesIngressNGINX.allowSnippetAnnotations=true
--providers.kubernetesIngressNGINX.httpentrypoint=web
--providers.kubernetesIngressNGINX.httpsentrypoint=websecure
--providers.kubernetescrd
2. Expose Ingress NGINX as a ClusterIP service
For Traefik to forward to Ingress NGINX, Ingress NGINX needs a stable internal address. We create a ClusterIP Service that selects the existing Ingress NGINX pods. The external LoadBalancer stays untouched for now, Ingress NGINX is reachable from two places, the LoadBalancer (still serving production) and the new ClusterIP (about to be used by Traefik).
# Internal ClusterIP Service for ingress-nginx
apiVersion: v1
kind: Service
metadata:
name: internal-nginx-svc
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: ingress-nginx
ports:
- name: http
port: 80
targetPort: 80
- name: https
port: 443
targetPort: 443
3. Add Catch-All Routes
We create two resources, one IngressRouteTCP and one IngressRoute, one for each entrypoint. These match every hostname and forward the request to Ingress NGINX. The TLS entry point uses passthrough mode, meaning ingress-nginx continues to terminate TLS exactly as before.
# Catch-all TCP route for TLS connections (TLS passthrough)
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: traefik-catchall-tcp-tls
spec:
entryPoints:
- websecure
routes:
- match: HostSNI(`*`)
services:
- name: internal-nginx-svc
port: 443
tls:
passthrough: true
---
# Catch-all HTTP route for non-TLS connections
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: traefik-catchall-http
spec:
entryPoints:
- web
routes:
- match: HostRegexp(`.*`)
services:
- name: internal-nginx-svc
port: 80
4. Swing the LoadBalancer to Traefik
To complete step 1, the final move is to update the external LoadBalancer selector from ingress-nginx to traefik. This is the only change visible to external clients, and it is instantaneous. Traefik starts receiving traffic and, because the only routes it knows about are the two catch-alls, forwards every request untouched to ingress-nginx.
apiVersion: v1
kind: Service
metadata:
name: ingress-controller
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: traefik # <= was ingress-nginx
ports:
- name: http
port: 80
targetPort: 80
- name: https
port: 443
targetPort: 443
New traffic flow:

There is no user impact: every request passes through Traefik and arrives at ingress-nginx exactly as before.
The Traefik dashboard confirms the state. Besides the routers that expose the dashboard itself, only two catch-all routers are active, one TCP and one HTTP, both at low priority.


Step 2: Migrate Ingress Resources One by One
With Traefik in place and a live fallback running, we can now move each Ingress resource to Traefik independently, at whatever pace suits the team.
The process is the same for each Ingress resource:
- Edit the manifest and change
ingressClassNamefrom nginx totraefik-nginx. - Apply.
- Traefik picks it up immediately and creates an HTTP router. Because the new router matches a specific host (or path), and the catch-all matches
*, the more specific rule wins. From that instant on, the Ingress is served by Traefik directly. - Confirm in the Traefik dashboard that the new router is active and green.
Here is the updated Ingress. Note that the only change is the IngressClass name:
# Ingress, updated to use the traefik-nginx IngressClass
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-with-nginx-annotation
annotations:
nginx.ingress.kubernetes.io/server-snippet: |
location = /exact {
return 200 "exact-match";
}
nginx.ingress.kubernetes.io/configuration-snippet: |
# Add Headers on the response
add_header X-Method $request_method;
set $my_var "hello";
add_header X-My-Var $my_var;
set $combined "$request_method-$request_uri";
add_header X-Combined $combined;
# Add/Set Headers on the request
proxy_set_header X-Backend-Uri $request_uri;
more_set_input_headers "X-Custom-Input:input-value";
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: traefik-nginx # <= changed from nginx
rules:
- host: whoami.docker.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
number: 80
tls:
- hosts:
- whoami.docker.localhost
secretName: external-certs
The annotations are unchanged. Traefik v3.7 reads them natively and translates them into the equivalent routing rules and middlewares. The audit you did in the first post is what makes this trustworthy: you already know, before you apply, that this Ingress falls in the Supported bucket.
After applying, the dashboard shows the new HTTP router active alongside the remaining catch-all routes:

At this point, two flows coexist in the cluster: one for the migrated Ingresses, and still the previous one for the not-yet-migrated Ingresses.

Rolling back is symmetrical: flip the IngressClass back to nginx, and the Ingress returns to ingress-nginx; the catch-all takes care of the rest. Nothing else in the cluster needs to know.
Repeat for each Ingress, at whatever cadence the team can handle. One per sprint, ten per afternoon, fifty in a coordinated push. The catch-all does not care.
Once the last Ingress has been migrated, Ingress NGINX is no longer needed. Clean up in two steps:
- Delete the catch-all
IngressRouteandIngressRouteTCPresources (traefik-catchall-httpandtraefik-catchall-tcp-tls). - Remove the ingress-nginx resources you don’t need anymore, keeping the Load-Balancer Service:
a. The Deployment (ingress-nginx-controller)
b. The internal ClusterIP service (internal-nginx-svc)
c. The ConfigMap (nginx-configuration)
d. The RBAC (ingress-nginxClusterRoleandClusterRoleBinding)
e. The ServiceAccount (ingress-nginx)
f. The original nginxIngressClass.
If you installed ingress-nginx via Helm, add the annotation helm.sh/resource-policy: keep to the ingress-nginx-controller Service prevents the uninstall from deleting it. That matters: that Service holds the LoadBalancer IP your DNS points at, you do not want to lose it.
Final traffic flow:

Et voilà. Traefik is now the sole Ingress Controller, handling every route with full native support for the existing annotation-based configuration, no manifest rewrites required.
Conclusion
The way most teams think about an Ingress controller migration is: we have to swap A for B, what is the safest moment to do it? The answer is always disappointing, because there is no safe moment to swap one production component for another.
The way to migrate is to stop framing it as a swap at all. Put Traefik on the traffic path with a catch-all, and from that point on, you are not migrating a controller; you are migrating Ingress resources, one at a time, each with its own atomic decision and its own one-line rollback. The catch-all is what makes the difference between "we need a maintenance window" and "we can do this Tuesday afternoon".
Traefik v3.7 makes the per-Ingress move trivial because it reads ingress-nginx annotations natively. The audit told you which Ingresses are in the safe bucket. The install put Traefik in the cluster without touching production. This post connects the two: with a single catch-all router, every Ingress migration becomes the smallest possible change.
Give it a try, and let the Traefik community know how it goes!



