Migrating from ingress-nginx to Envoy Gateway - Mijndert Stuij
Migrating from ingress-nginx to Envoy Gateway
May 18, 2026
If you're even remotely into Kubernetes, you've probably heard about the ingress-nginx retirement. It's no longer maintained so it has to get replaced with something else. That something else is obviously something to do with Gateway API because it's the new hot thing everyone should be using.
I tried a bunch on a separate EKS cluster, like HAProxy and Traefik, but eventually I landed on using Envoy Gateway since to me it felt like the most approachable and stable solution for our use case.
Since I started the migration I made a respectable number of mistakes. These are my notes.
TL;DR
Run one shared Gateway per audience (shared-public, shared-private), not one per service.
The HTTPRoute itself is simple; the HTTPS redirect is a second HTTPRoute attached to the http listener.
nginx annotations don't map 1:1 — they split across SecurityPolicy, BackendTrafficPolicy, and ClientTrafficPolicy, and some live in the platform layer rather than the service chart.
Cross-namespace TLS secrets need a ReferenceGrant in the secret's namespace.
Argo Rollouts on Gateway API needs the rollouts-plugin-trafficrouter-gatewayapi plugin. Plan for it as a separate workstream.
The shape of the migration
We use a monorepo of Helm charts for all of our in-house applications, which we deploy using ArgoCD. Each chart had an Ingress template which for the migration I guarded by ingress.enabled. Strategy:
Add an HTTPRoute template (and a gateway: values block) to every chart, opt-in via gateway.enabled: true.
Flip services to Envoy Gateway one at a time, staging first and production 1–2 weeks later. Yes, this is a DNS change.
Keep both the Ingress and HTTPRoute resources in the chart during the transition so I can flip back without a code change. Set ingress.enabled: false once the dust has settled.
Decommission ingress-nginx once everything is on Envoy Gateway.
Lesson 1: don't give every application its own Gateway
The first version of the templates rendered a Gateway resource per chart:
# what NOT to do — gateway per app<br>gateway:<br>enabled: true<br>gatewayClassName: envoy-gateway-private<br>listeners:<br>- name: example-app-http<br>hostname: app.example.com<br>port: 80<br>protocol: HTTP<br>That works, but you end up with one LoadBalancer per service, separate TLS termination per app, and certs sprayed across namespaces. Within a month I deleted all of those and consolidated to two shared Gateways: shared-public and shared-private living in the envoy-gateway-system namespace. Service charts attach to them via parentRef:
gateway:<br>enabled: true<br>parentRef:<br>name: shared-public<br>namespace: envoy-gateway-system<br>sectionName: https<br>hostnames:<br>- app.example.com<br>If you're starting fresh: don't even render the Gateway resource from your service charts. Put it in a separate platform chart that the cluster owns. Service charts only own HTTPRoute.
Lesson 2: the HTTPRoute is the easy part
Once the shared Gateways exist, the HTTPRoute template is almost embarrassingly simple:
{{- if .Values.gateway.enabled -}}<br>apiVersion: gateway.networking.k8s.io/v1<br>kind: HTTPRoute<br>metadata:<br>name: {{ .Values.appName }}-httproute<br>spec:<br>parentRefs:<br>- name: {{ .Values.gateway.parentRef.name }}<br>namespace: {{ .Values.gateway.parentRef.namespace }}<br>sectionName: {{ .Values.gateway.parentRef.sectionName }}<br>hostnames:<br>{{- range .Values.gateway.hostnames }}<br>- {{ . | quote }}<br>{{- end }}<br>rules:<br>- matches:<br>- path:<br>type: PathPrefix<br>value: /<br>backendRefs:<br>- name: {{ .Values.appName }}<br>port: 3000<br>{{- end }}<br>That's it. No annotations, no controller-specific knobs, no nginx.ingress.kubernetes.io/* configuration mini-language (more on that later). It's the cleanest part of the move.
Lesson 3: HTTPS redirect is a second resource
If you're used to nginx.ingress.kubernetes.io/ssl-redirect: "true", the Gateway API equivalent is a second HTTPRoute attached to the http listener that 301s to https:
apiVersion: gateway.networking.k8s.io/v1<br>kind: HTTPRoute<br>metadata:<br>name: {{ .Values.appName }}-https-redirect<br>spec:<br>parentRefs:<br>- name: {{ .Values.gateway.parentRef.name }}<br>namespace: {{ .Values.gateway.parentRef.namespace }}<br>sectionName: {{ .Values.gateway.httpsRedirect.sectionName }} # http<br>hostnames: {{ .Values.gateway.hostnames }}<br>rules:<br>- filters:<br>- type: RequestRedirect<br>requestRedirect:<br>scheme: https<br>statusCode: 301<br>It's a route doing redirects so it's conceptually cleaner, but it doubles the number of resources to remember per public-facing service. I gated it behind a gateway.httpsRedirect.enabled flag so it's completely optional. Most of our services run on HTTPS only anyway.
Lesson 4: sectionName will burn you
This is the single thing that's bitten me hardest. parentRef.sectionName selects which listener on the shared Gateway your route attaches to, typically http (port 80) or https (port 443). Get it wrong and:
Your HTTPRoute attaches successfully (status...