Ingress NGINX is in maintenance mode, and the Kubernetes project has been nudging everyone toward Gateway API for a while now. The networking spec graduated to GA (v1) for GatewayClass, Gateway and HTTPRoute, the ecosystem has converged, and your next cluster upgrade is a good excuse to stop postponing the migration.

The problem is that "replace your ingress controller" is a sentence that hides a lot of risk: a single typo in a TLS listener and every browser in production gets a certificate error. This article walks through a migration strategy that never requires a big-bang cutover: run Gateway API next to your existing Ingress NGINX, move one hostname at a time, shift traffic by weight, and validate the TLS chain at every step.

We'll use Envoy Gateway as the controller and cert-manager for certificates, but the pattern applies to any conformant implementation (Istio, Cilium, Traefik, NGINX Gateway Fabric).

Context: why this migration is different

Swapping an ingress controller is not like swapping a Deployment image. Three things make it risky:

  1. TLS termination moves. Your certificates live in Secrets referenced by Ingress objects. The new data plane has to reference the same Secrets (or new ones), possibly from a different namespace, under a stricter cross-namespace model.
  2. The public IP changes. A new controller means a new LoadBalancer Service, a new external IP, and a DNS change. DNS changes are not atomic — you cannot roll them back in 30 seconds.
  3. Annotations don't translate. Rewrites, CORS, rate limits, auth, timeouts — everything encoded in nginx.ingress.kubernetes.io/* annotations becomes either native Gateway API fields, filters, or implementation-specific policies.

The good news: Gateway API was explicitly designed for coexistence. An Ingress and a Gateway can serve the same hostname from the same cluster, and nothing in the spec forbids two controllers from watching different resources. That's the lever we're going to pull.

Target architecture

The model splits responsibilities across three object types, which maps nicely onto real org boundaries:

  • GatewayClass — cluster-scoped, owned by the platform team. Points at a controller.
  • Gateway — the listener (ports, protocols, TLS certs, allowed routes). Lives in an infrastructure namespace.
  • HTTPRoute — owned by application teams, in application namespaces, attached to a Gateway via parentRefs.
  • ReferenceGrant — the explicit opt-in that allows a cross-namespace reference (Gateway → Secret, HTTPRoute → Service).

Concretely: one shared Gateway in gateway-infra, TLS Secrets managed by cert-manager, and each team shipping its own HTTPRoute in its own namespace.

Step 1 — Install the CRDs and the controller

On a vanilla Kubernetes cluster, install the standard channel CRDs first:

kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml kubectl get crd | grep gateway.networking.k8s.io 

OpenShift note: since 4.19, the Ingress Operator can manage the Gateway API CRDs itself. Check for ownership before applying anything, otherwise the operator will fight you:

oc get crd gateways.gateway.networking.k8s.io \ -o jsonpath='{.metadata.labels}{"\n"}{.metadata.annotations}{"\n"}' 

If the CRDs are operator-managed, skip the kubectl apply and only install your controller. Envoy Gateway's data plane runs unprivileged on ports 10080/10443, so the restricted-v2 SCC is sufficient — no SCC patching required.

Install Envoy Gateway:

helm install eg oci://docker.io/envoyproxy/gateway-helm \ --version v1.2.6 \ -n envoy-gateway-system --create-namespace \ --set config.envoyGateway.provider.kubernetes.rateLimitDeployment.container.resources.requests.cpu=100m kubectl -n envoy-gateway-system rollout status deploy/envoy-gateway 

Then declare the GatewayClass:

apiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: name: eg-external spec: controllerName: gateway.envoyproxy.io/gatewayclass-controller 
kubectl get gatewayclass eg-external \ -o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}{"\n"}' # expected: True 

Step 2 — Wire cert-manager to Gateway API

cert-manager ships a "gateway shim" that watches Gateway objects for the cert-manager.io/cluster-issuer annotation and creates Certificate resources from the listeners. It's off by default:

helm upgrade --install cert-manager jetstack/cert-manager \ -n cert-manager --create-namespace \ --version v1.16.2 \ --set crds.enabled=true \ --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \ --set config.kind="ControllerConfiguration" \ --set config.enableGatewayAPI=true 

For HTTP-01 solving, cert-manager can create a temporary HTTPRoute instead of an Ingress:

apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-prod spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: platform@example.com privateKeySecretRef: name: letsencrypt-prod-account-key solvers: - http01: gatewayHTTPRoute: parentRefs: - name: apps-gw namespace: gateway-infra kind: Gateway 

Critical detail during coexistence: if your existing Ingress NGINX still serves the hostname and holds the DNS record, HTTP-01 challenges will land on NGINX, not on the Gateway. Two safe options: keep issuing certificates through the existing Ingress solver until DNS is switched, or — far better — use DNS-01. DNS-01 is independent of which data plane owns the IP, which removes an entire class of migration failures.

Step 3 — The Gateway, reusing the existing certificate

Here's the trick that avoids re-issuing certs on day one: point the new Gateway at the same Secret your Ingress already uses. No new ACME order, no rate-limit risk, identical chain.

apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: apps-gw namespace: gateway-infra annotations: cert-manager.io/cluster-issuer: letsencrypt-prod spec: gatewayClassName: eg-external listeners: - name: http protocol: HTTP port: 80 hostname: "*.example.com" allowedRoutes: namespaces: from: Selector selector: matchLabels: gateway-access: "true" - name: https-shop protocol: HTTPS port: 443 hostname: shop.example.com tls: mode: Terminate certificateRefs: - kind: Secret name: shop-example-com-tls namespace: shop allowedRoutes: namespaces: from: Selector selector: matchLabels: gateway-access: "true" 

Two things to notice. First, allowedRoutes uses a label selector, not All — namespaces must explicitly opt in. Second, the certificate Secret lives in the shop namespace while the Gateway lives in gateway-infra. That cross-namespace reference is denied by default. You need a ReferenceGrant in the namespace that owns the Secret:

apiVersion: gateway.networking.k8s.io/v1beta1 kind: ReferenceGrant metadata: name: allow-gw-to-read-shop-tls namespace: shop spec: from: - group: gateway.networking.k8s.io kind: Gateway namespace: gateway-infra to: - group: "" kind: Secret name: shop-example-com-tls 

Note the name field in to: — grant access to that one Secret, not to every Secret in the namespace.

Verify the listener is actually programmed. This is the single most useful command of the whole migration:

kubectl -n gateway-infra get gateway apps-gw \ -o jsonpath='{range .status.listeners[*]}{.name}{"\t"}{range .conditions[*]}{.type}={.status}{" "}{end}{"\n"}{end}' 

A ResolvedRefs=False with reason RefNotPermitted means your ReferenceGrant is missing or mismatched.

Step 4 — The HTTPRoute

apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: shop namespace: shop spec: parentRefs: - name: apps-gw namespace: gateway-infra sectionName: https-shop hostnames: - shop.example.com rules: - matches: - path: type: PathPrefix value: /api filters: - type: RequestHeaderModifier requestHeaderModifier: set: - name: X-Forwarded-Proto value: https backendRefs: - name: shop-api port: 8080 - matches: - path: type: PathPrefix value: / backendRefs: - name: shop-frontend port: 8080 

Don't forget the namespace label required by allowedRoutes:

kubectl label namespace shop gateway-access=true 

If a rule needs to target a Service in another namespace (say payments), add a ReferenceGrant in payments with from.kind: HTTPRoute. Same mechanism, same least-privilege rule: name the target Service explicitly.

Step 5 — Validate TLS before touching DNS

At this point both data planes serve shop.example.com, but only NGINX has the DNS record. Test the Gateway directly by resolving the hostname yourself:

GW_IP=$(kubectl -n envoy-gateway-system get svc \ -l gateway.envoyproxy.io/owning-gateway-name=apps-gw \ -o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}') curl -sSv --resolve shop.example.com:443:$GW_IP https://shop.example.com/healthz 

Then compare the certificate served by each data plane. Identical fingerprints mean zero client-visible change:

fp() { openssl s_client -connect "$1:443" -servername shop.example.com </dev/null 2>/dev/null \ | openssl x509 -noout -fingerprint -sha256; } NGINX_IP=$(dig +short shop.example.com | head -1) echo "nginx: $(fp $NGINX_IP)" echo "gateway: $(fp $GW_IP)" 

Also check the full chain and SAN list — a frequent failure mode is a Gateway serving only the leaf certificate because the Secret was built by hand:

openssl s_client -connect $GW_IP:443 -servername shop.example.com -showcerts </dev/null 2>/dev/null \ | grep -E 's:|i:' openssl s_client -connect $GW_IP:443 -servername shop.example.com </dev/null 2>/dev/null \ | openssl x509 -noout -dates -ext subjectAltName 

And make sure an unknown SNI doesn't silently fall through to the wrong certificate:

openssl s_client -connect $GW_IP:443 -servername notmydomain.example.com </dev/null 2>&1 | head -20 

Step 6 — Shift traffic by weight, without DNS

DNS-based cutovers are coarse: TTLs lie, resolvers cache, and rollback takes minutes. A much tighter loop is to make Ingress NGINX itself forward a percentage of traffic to the Gateway, using its canary annotations plus an ExternalName Service.

apiVersion: v1 kind: Service metadata: name: gw-bridge namespace: shop spec: type: ExternalName externalName: envoy-gateway-infra-apps-gw-7f9c2d11.envoy-gateway-system.svc.cluster.local ports: - name: https port: 443 

Get the real Service name first:

kubectl -n envoy-gateway-system get svc \ -l gateway.envoyproxy.io/owning-gateway-name=apps-gw -o name 

Then a canary Ingress on the same hostname:

apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: shop-canary-to-gateway namespace: shop annotations: nginx.ingress.kubernetes.io/canary: "true" nginx.ingress.kubernetes.io/canary-weight: "5" nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" nginx.ingress.kubernetes.io/upstream-vhost: "shop.example.com" nginx.ingress.kubernetes.io/proxy-ssl-server-name: "on" nginx.ingress.kubernetes.io/proxy-ssl-name: "shop.example.com" spec: ingressClassName: nginx tls: - hosts: [shop.example.com] secretName: shop-example-com-tls rules: - host: shop.example.com http: paths: - path: / pathType: Prefix backend: service: name: gw-bridge port: number: 443 

Now you have a real dial. Ramp it with a single patch, watch your SLOs, and roll back in seconds:

for w in 5 25 50 100; do kubectl -n shop annotate ingress shop-canary-to-gateway \ nginx.ingress.kubernetes.io/canary-weight="$w" --overwrite sleep 600 done # instant rollback kubectl -n shop annotate ingress shop-canary-to-gateway \ nginx.ingress.kubernetes.io/canary-weight="0" --overwrite 

You can also pin the canary to a header (nginx.ingress.kubernetes.io/canary-by-header: x-use-gateway) so QA can target the new path deterministically before any percentage of real users is affected.

Once 100% has been stable for a few days, switch DNS to the Gateway's IP, wait out the TTL, then delete the canary Ingress and the legacy Ingress. The same weighting primitive exists natively inside Gateway API for backend-level canaries:

 backendRefs: - name: shop-frontend port: 8080 weight: 90 - name: shop-frontend-next port: 8080 weight: 10 

Security best practices

Never use allowedRoutes.namespaces.from: All on an internet-facing Gateway. Any namespace in the cluster could then attach a route to your public listener and claim a hostname. Use Same or a label Selector, and treat the label as a privileged operation controlled by the platform team.

Scope every ReferenceGrant. Always set to[].name. A grant without a name allows every Secret (or every Service) of that kind in the namespace — including, potentially, the Secret holding a wildcard certificate.

Enforce TLS floor and cipher policy. Gateway API leaves this to implementation-specific policies. With Envoy Gateway:

apiVersion: gateway.envoyproxy.io/v1alpha1 kind: ClientTrafficPolicy metadata: name: tls-hardening namespace: gateway-infra spec: targetRefs: - group: gateway.networking.k8s.io kind: Gateway name: apps-gw tls: minVersion: "1.2" maxVersion: "1.3" ciphers: - ECDHE-ECDSA-AES256-GCM-SHA384 - ECDHE-RSA-AES256-GCM-SHA384 - ECDHE-ECDSA-CHACHA20-POLY1305 headers: xForwardedClientCert: mode: Sanitize 

Redirect HTTP to HTTPS at the route level, so a misconfigured app can never be served in clear text:

apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: https-redirect namespace: shop spec: parentRefs: - name: apps-gw namespace: gateway-infra sectionName: http hostnames: ["shop.example.com"] rules: - filters: - type: RequestRedirect requestRedirect: scheme: https statusCode: 301 

Encrypt the last hop too. If your backend speaks TLS, use BackendTLSPolicy with explicit CA validation and hostname verification rather than disabling verification:

apiVersion: gateway.networking.k8s.io/v1alpha3 kind: BackendTLSPolicy metadata: name: shop-api-upstream-tls namespace: shop spec: targetRefs: - group: "" kind: Service name: shop-api sectionName: "8443" validation: caCertificateRefs: - group: "" kind: ConfigMap name: internal-ca hostname: shop-api.shop.svc.cluster.local 

Lock down the data plane with NetworkPolicy. The Envoy pods should accept traffic from the LoadBalancer and reach only the namespaces they route to. On OpenShift, confirm the proxy runs under restricted-v2 and not a relaxed SCC:

oc -n envoy-gateway-system get pod -l app.kubernetes.io/name=envoy \ -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.openshift\.io/scc}{"\n"}{end}' 

Audit certificate ownership after the switch. When you finally delete the legacy Ingress, make sure cert-manager still owns the Certificate object — deleting an Ingress with the cert-manager.io/cluster-issuer annotation can garbage-collect the Certificate and, depending on your setup, the Secret with it. Create a standalone Certificate resource explicitly rather than relying on annotation-driven shims for anything production-critical:

apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: shop-example-com namespace: shop spec: secretName: shop-example-com-tls duration: 2160h renewBefore: 720h privateKey: algorithm: ECDSA size: 256 rotationPolicy: Always dnsNames: - shop.example.com issuerRef: name: letsencrypt-prod kind: ClusterIssuer 

Alert on expiry independently. Scrape certmanager_certificate_expiration_timestamp_seconds and alert at 14 days, plus a black-box probe that checks the certificate actually served on port 443. A valid Secret that the Gateway failed to load is a silent outage waiting for renewal day.

Conclusion

Migrating off Ingress NGINX doesn't have to be a maintenance-window event. The key takeaways:

  • Coexist first. Gateway API and Ingress can serve the same hostname simultaneously. Build the Gateway, validate it out-of-band with curl --resolve, and only then send it traffic.
  • Reuse the existing TLS Secret for the first cutover. Comparing SHA-256 fingerprints between the old and new data plane is a 10-second check that eliminates the scariest failure mode.
  • ReferenceGrant is a security boundary, not a formality. Scope it to a named Secret or Service, and keep allowedRoutes on a label selector.
  • Shift traffic with weights, not DNS. The NGINX canary + ExternalName bridge gives you a percentage dial with sub-second rollback. Move DNS only once 100% has been stable for days.
  • Prefer DNS-01 during the transition, so certificate issuance never depends on which data plane currently owns the public IP.
  • Harden explicitly. TLS floor, HTTP→HTTPS redirect, upstream TLS validation and NetworkPolicy are all policy objects you have to write — nothing is secure by default just because it's new.

Do one low-traffic hostname end to end first. The second one takes an afternoon, and by the fifth you'll be deleting Ingress objects without thinking about it.