Lesson 30 of 66 · 16 min read · Updated

From Ingress to Gateway API — Why Kubernetes Is Moving On

In the Services series you exposed kiosk-api to the outside world with NodePort and LoadBalancer Services. Both work, but both operate at layer 4. They forward TCP connections and know nothing about hostnames, paths, headers, or TLS certificates. A real platform needs one entry point that looks at an HTTP request and decides which Service should answer it.

For almost ten years that entry point was Ingress. It is still in nearly every cluster, nearly every tutorial, and nearly every Helm chart. It is also the past. The most popular Ingress controller, Ingress NGINX, has been retired, and the Kubernetes project now points everyone to Gateway API instead.

This article opens a new series. Before you install anything new, you need to understand what you are moving away from, why the move is happening, and what in your cluster will have to change.

We will cover the following topics:

  1. What Ingress is: the resource, the controller, and the IngressClass that connects them
  2. A complete Ingress for kiosk-api, read line by line
  3. The structural limits of Ingress and why annotations became a problem
  4. The Ingress NGINX retirement and what it does and does not mean
  5. Gateway API as the successor, and its role-oriented design
  6. How every Ingress concept maps to a Gateway API resource
  7. The main Gateway API implementations you can choose from
  8. How to audit a cluster so you know exactly what must migrate

Technical Requirements

  1. A working Kubernetes cluster and kubectl. The examples use minikube.
  2. The kiosk-api:1.0 image from the Services series. The Step-by-Step section shows how to rebuild it if you do not have it.
  3. jq for the audit commands.
  4. Optionally the minikube ingress addon, which installs the Ingress NGINX controller. You only need it if you want to see the Ingress actually serve traffic.

Core Concepts

What Ingress Is

Ingress is two things that people usually blur into one.

The first is the Ingress resource, an object in the networking.k8s.io/v1 API. It is pure configuration. It says "requests for this host and this path should go to this Service on this port". On its own it does nothing at all.

The second is an Ingress controller, a reverse proxy running in the cluster that watches Ingress resources and turns them into its own routing configuration. Ingress NGINX, Traefik, HAProxy, and Contour are all Ingress controllers. Kubernetes does not ship one. If no controller is installed, you can create as many Ingress objects as you like and no request will ever reach your Pods.

The link between the two is the IngressClass. A cluster can run several controllers at once, so each Ingress names the class it belongs to with spec.ingressClassName, and each controller only processes the Ingresses of its own class.

PieceKind of thingWho creates it
Ingress controllerA Deployment running a proxyCluster operator, usually with Helm
IngressClassCluster-scoped API objectInstalled with the controller
IngressNamespaced API objectApplication developer

An Ingress for kiosk-api

Here is a typical Ingress. It sends storefront.local/kiosk to the kiosk-api Service and strips the prefix so the .NET app sees /.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kiosk-api
namespace: storefront
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
ingressClassName: nginx
rules:
- host: storefront.local
http:
paths:
- path: /kiosk(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: kiosk-api
port:
number: 80

Read it from the bottom up. The backend is a Service and a port, exactly what you built in the Services series. The path and host decide which requests match. The ingressClassName decides which controller cares about this object.

Then look at the annotations. The path is a regular expression, and the rewrite uses a capture group. Neither of those exists in the Ingress specification. They are features of one specific controller, smuggled in through free-form strings. That is the heart of the problem with Ingress.

The Limits of Ingress

Ingress was designed in 2015 for a simple job: host and path routing for HTTP. It reached v1 in Kubernetes 1.19 and has barely changed since. Everything people needed beyond that simple job ended up in annotations.

LimitWhat it means in practice
Tiny core specOnly host, path, backend, and TLS are standard. Rewrites, redirects, timeouts, CORS, rate limits, and header matching are not.
Annotation sprawlEach controller invented its own annotations. Ingress NGINX alone has more than a hundred. They are untyped strings, so a typo is silently ignored.
Not portableAn Ingress full of nginx.ingress.kubernetes.io/* annotations means nothing to Traefik or to a cloud load balancer. Switching controllers means rewriting every object.
One role, one objectThe same object holds the hostname, the TLS certificate, and the routing rules. Platform teams and application teams fight over who owns it.
HTTP onlyThere is no standard way to route TCP, UDP, gRPC, or TLS passthrough.
No traffic splittingWeighted canaries need yet more annotations, or a second "canary Ingress", depending on the controller.
Weak statusAn Ingress tells you almost nothing about whether the controller accepted it, or why not.

The annotations were also a security problem. Some Ingress NGINX annotations accepted raw NGINX configuration snippets. Anyone who could create an Ingress could inject configuration into a proxy shared by the whole cluster. Several serious CVEs, including the 2025 "IngressNightmare" vulnerabilities, came from exactly that design.

The Ingress NGINX Retirement

In November 2025 Kubernetes SIG Network and the Security Response Committee announced the retirement of the Ingress NGINX controller. The project received best-effort maintenance until March 2026. Since then there are no new releases, no bug fixes, and no security patches.

It is important to be precise about what this means:

  1. Existing installations keep working. Nothing is switched off. The images and Helm charts are still downloadable.
  2. Every new vulnerability stays open. An internet-facing proxy that never gets patched is a liability that grows every month.
  3. Only the controller is retired. The kubernetes/ingress-nginx project is gone. The separate NGINX Ingress Controller made by F5/NGINX is a different product and is not affected.
  4. The Ingress API is not removed. The Ingress kind remains in Kubernetes with no announced deprecation, but it is feature-frozen. It will not gain new capabilities.

The official recommendation is to migrate to Gateway API. If you must stay on Ingress for a while, move to another maintained Ingress controller. Either way, planning the migration starts with knowing what you have, which is what the rest of this article is about.

Gateway API, the Successor

Gateway API is a set of Kubernetes APIs developed by SIG Network as the next generation of Ingress. It is not built into the API server. You install it as Custom Resource Definitions, usually bundled with the implementation you choose. Its core resources reached v1 (GA) in October 2023, and each release since then has promoted more features into the stable Standard channel. Release v1.5 in February 2026 moved ListenerSet, TLSRoute, ReferenceGrant, and the HTTPRoute CORS filter into Standard. The current release line is v1.6.

The biggest change is that Gateway API splits the old Ingress object into several resources, each owned by a different role:

RoleTypical ownerResource they manage
Infrastructure providerCloud vendor or the team running the proxy softwareGatewayClass
Cluster operatorPlatform teamGateway (ports, hostnames, TLS, who may attach)
Application developerThe team owning kiosk-apiHTTPRoute, GRPCRoute, and other routes

The platform team decides that the cluster listens on port 443 for *.storefront.local with a given certificate. Application teams then attach their own routes to that Gateway from their own namespaces, without ever touching the shared object. Kubernetes RBAC can enforce the split, because each role edits a different kind.

Other differences matter just as much:

  1. Expressive core. Header, query parameter, and method matching, redirects, rewrites, header modification, request mirroring, and weighted traffic splitting are all typed fields, not annotations.
  2. Protocol aware. Besides HTTPRoute there are GRPCRoute, TLSRoute, and experimental TCPRoute and UDPRoute.
  3. Portable. A route that only uses Standard features behaves the same on every conformant implementation.
  4. Rich status. Every Gateway and Route reports conditions such as Accepted, Programmed, and ResolvedRefs, with a reason when something is wrong.
  5. Mesh support. The same route types can describe traffic between Services inside a service mesh, not just traffic entering the cluster.

Mapping Ingress to Gateway API

Every Ingress concept has a home in Gateway API, which makes migration mostly mechanical.

IngressGateway API
Ingress controller installationAn implementation, such as Envoy Gateway, plus its GatewayClass
IngressClassGatewayClass
The controller's shared listening portsGateway with one or more listeners
spec.rules[].hostHTTPRoute.spec.hostnames, optionally restricted by the listener hostname
spec.rules[].http.paths[]HTTPRoute.spec.rules[].matches[].path
pathType: Prefix / Exacttype: PathPrefix / Exact
backend.serviceHTTPRoute.spec.rules[].backendRefs[]
spec.tlsAn HTTPS listener on the Gateway with certificateRefs
spec.defaultBackendA rule without matches, which matches everything
Rewrite annotationsURLRewrite filter
Redirect annotationsRequestRedirect filter
Canary annotationsSeveral backendRefs with weight
Header-based routing annotationsmatches[].headers

The kiosk-api Ingress above becomes one Gateway, owned by the platform team, and one HTTPRoute, owned by you. You will write both in the next article.

Choosing an Implementation

Gateway API is only a specification. Something must implement it. The conformance reports on the Gateway API website list which features each implementation supports.

ImplementationNotes
Envoy GatewayCNCF Envoy project's own controller. Built for Gateway API from day one. Used throughout this series.
NGINX Gateway FabricNGINX-based implementation from F5/NGINX. A natural landing place for Ingress NGINX users.
IstioUses Gateway API for both ingress and mesh traffic.
CiliumImplements Gateway API inside its eBPF-based CNI.
TraefikSupports Gateway API alongside its own Ingress support.
kgateway, Kong, HAProxy, ContourOther Envoy- or proxy-based options.
Cloud controllersGKE Gateway controller, AWS Load Balancer Controller and VPC Lattice, Azure Application Gateway for Containers.

The resources you write stay the same whichever you choose. Only the GatewayClass and implementation-specific extensions change.

Hands-On: Kubernetes Commands

List the IngressClasses in the cluster

Each class is backed by a controller. The CONTROLLER column tells you which one.

kubectl get ingressclass

List every Ingress in every namespace

This is your migration inventory. The CLASS column shows which controller serves each one.

kubectl get ingress -A

Check whether Ingress NGINX is running

This is the check recommended in the official retirement announcement. Any result means you are affected.

kubectl get pods -A --selector app.kubernetes.io/name=ingress-nginx

Find every Ingress NGINX annotation in use

Annotations are the hard part of a migration, because each one needs a Gateway API equivalent.

kubectl get ingress -A -o json | jq -r '.items[] | .metadata.namespace + "/" + .metadata.name + ": " + ((.metadata.annotations // {}) | keys | map(select(startswith("nginx.ingress.kubernetes.io"))) | join(", "))'

Look for configuration snippets

Snippets inject raw NGINX configuration. They have no portable equivalent and need manual review.

kubectl get ingress -A -o json | jq -r '.items[] | select((.metadata.annotations // {}) | keys | any(test("snippet"))) | .metadata.namespace + "/" + .metadata.name'

Check whether the Gateway API CRDs are installed

No output means Gateway API is not installed yet.

kubectl get crd | grep gateway.networking.k8s.io

List the Gateway API kinds the cluster knows

Useful to see which channel and version of the CRDs is present.

kubectl api-resources --api-group=gateway.networking.k8s.io

Step-by-Step Example

The Scenario

The storefront team runs kiosk-api behind Ingress NGINX. The platform team has read the retirement announcement and wants an inventory before choosing a migration date. You will deploy kiosk-api with a typical Ingress, look at it the way the controller does, and produce the list of things that must change.

Step 1 — Namespace and Application

If you still have the storefront namespace and the kiosk-api Deployment and Service from the Services series, skip to Step 2. Otherwise, build the image inside minikube's Docker daemon so the cluster can use it without a registry:

eval $(minikube docker-env)
dotnet new web -n KioskApi
cd KioskApi

Replace Program.cs:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

string podName = Environment.GetEnvironmentVariable("POD_NAME") ?? Environment.MachineName;
string podIp = Environment.GetEnvironmentVariable("POD_IP") ?? "unknown";
string nodeName = Environment.GetEnvironmentVariable("NODE_NAME") ?? "unknown";

app.MapGet("/healthz", () => Results.Ok("healthy"));

app.MapGet("/", (HttpContext context) => Results.Ok(new
{
servedByPod = podName,
podIp,
node = nodeName,
path = context.Request.Path.Value
}));

app.Run();

Add a Dockerfile and build:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish KioskApi.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "KioskApi.dll"]
docker build -t kiosk-api:1.0 .

On Docker Desktop's built-in Kubernetes with the kind provisioner, skip eval $(minikube docker-env): images you build with docker build are available to the cluster nodes directly. On a plain kind cluster, run kind load docker-image kiosk-api:1.0 after the build. In both cases, a node that already has kiosk-api:1.0 keeps using its cached copy under imagePullPolicy: IfNotPresent, so give a rebuilt image a new tag.

Save the namespace, Deployment, and Service as 1-kiosk-api.yaml:

apiVersion: v1
kind: Namespace
metadata:
name: storefront
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kiosk-api
namespace: storefront
labels:
app: kiosk-api
spec:
replicas: 3
selector:
matchLabels:
app: kiosk-api
template:
metadata:
labels:
app: kiosk-api
spec:
containers:
- name: kiosk-api
image: kiosk-api:1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: kiosk-api
namespace: storefront
spec:
selector:
app: kiosk-api
ports:
- name: http
port: 80
targetPort: http
kubectl apply -f 1-kiosk-api.yaml
kubectl rollout status deployment kiosk-api -n storefront

Step 2 — Optionally Install Ingress NGINX

The minikube ingress addon installs the Ingress NGINX controller. Install it only to see the Ingress work end to end. Do not use it for anything new.

minikube addons enable ingress
kubectl get pods -n ingress-nginx
NAME READY STATUS RESTARTS AGE
ingress-nginx-admission-create-x8k2m 0/1 Completed 0 52s
ingress-nginx-admission-patch-q7wrd 0/1 Completed 1 52s
ingress-nginx-controller-7c6974c4d8-4kz9p 1/1 Running 0 52s

On Docker Desktop or kind there is no addon. Install the same controller from its Helm chart; it still downloads, it just gets no updates:

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace --wait

If the first kubectl apply of an Ingress right after the install fails with failed calling webhook "validate.nginx.ingress.kubernetes.io" ... x509: certificate signed by unknown authority, the admission webhook is still being set up. Wait a few seconds and apply again.

If you skip this step, everything below still works. The Ingress object is accepted by the API server, it just has no controller to act on it, which is a useful lesson in itself.

Step 3 — Create the Ingress

Save the Ingress from the Core Concepts section as 2-kiosk-api-ingress.yaml and apply it:

kubectl apply -f 2-kiosk-api-ingress.yaml
kubectl get ingress -n storefront
NAME CLASS HOSTS ADDRESS PORTS AGE
kiosk-api nginx storefront.local 192.168.49.2 80 40s

The ADDRESS column is filled in by the controller. Without a controller it stays empty forever.

Step 4 — Send a Request Through the Ingress

With the addon installed, send a request to the minikube IP with the right Host header:

curl -H "Host: storefront.local" http://$(minikube ip)/kiosk/
{"servedByPod":"kiosk-api-6b9d7f5c9-2lq8v","podIp":"10.244.0.14","node":"minikube","path":"/"}

On Docker Desktop's kind cluster the controller's LoadBalancer Service is published on localhost, so the same request is curl -H "Host: storefront.local" http://localhost/kiosk/. The 172.18.x.x address shown in the ADDRESS column is inside Docker's network and not reachable from macOS or Windows.

The app saw /, not /kiosk/. The rewrite annotation did that. Remember it, because the rewrite is exactly the kind of behaviour that has to be translated, not copied, during a migration.

Step 5 — See What the API Server Actually Stored

Describe the Ingress. Look at what Kubernetes understands and what it does not.

kubectl describe ingress kiosk-api -n storefront
Name: kiosk-api
Namespace: storefront
Address: 192.168.49.2
Ingress Class: nginx
Rules:
Host Path Backends
---- ---- --------
storefront.local
/kiosk(/|$)(.*) kiosk-api:80 (10.244.0.14:8080,10.244.0.15:8080,10.244.0.16:8080)
Annotations: nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/use-regex: true

The rules are structured data. The annotations are just strings. The API server has no idea that rewrite-target changes the request path. If you mistype it as rewrite-targett, the apply succeeds and the rewrite silently stops working.

Step 6 — Run the Audit

Now produce the inventory a migration needs. First, which controllers and classes exist:

kubectl get ingressclass
kubectl get pods -A --selector app.kubernetes.io/name=ingress-nginx

Then every Ingress with its controller-specific annotations:

kubectl get ingress -A -o json | jq -r '.items[] | .metadata.namespace + "/" + .metadata.name + ": " + ((.metadata.annotations // {}) | keys | map(select(startswith("nginx.ingress.kubernetes.io"))) | join(", "))'
storefront/kiosk-api: nginx.ingress.kubernetes.io/rewrite-target, nginx.ingress.kubernetes.io/use-regex

Finally, check whether Gateway API is already present:

kubectl get crd | grep gateway.networking.k8s.io

On a fresh minikube this prints nothing. You will fix that in the next article.

Step 7 — Write Down the Migration Plan

For a real cluster you would put the audit output in a table like this one. For kiosk-api it is short:

Ingress featureWhere it is usedGateway API equivalent
Host storefront.localstorefront/kiosk-apiHTTPRoute.spec.hostnames
Regex path under /kioskstorefront/kiosk-apiPathPrefix match on /kiosk
rewrite-target: /$2storefront/kiosk-apiURLRewrite filter with ReplacePrefixMatch: /
Class nginxstorefront/kiosk-apiparentRefs to a Gateway of class eg

Notice the second row. A regex path was only needed because the rewrite needed capture groups. Gateway API can strip a prefix directly, so the regex disappears. Many migrations get simpler, not harder.

Step 8 — Clean Up

Delete the Ingress and, if you enabled it, the addon. Keep the storefront namespace and kiosk-api, because the next article uses them.

kubectl delete ingress kiosk-api -n storefront
minikube addons disable ingress

If you installed the controller with Helm, use helm uninstall ingress-nginx -n ingress-nginx instead of the addon command.

Summary

  1. Ingress is a resource plus a controller, linked by an IngressClass. Without a controller an Ingress does nothing.
  2. The Ingress spec only covers host, path, backend, and TLS. Everything else lives in controller-specific annotations, which are untyped, unportable, and in some cases a security risk.
  3. The Ingress NGINX controller is retired. Since March 2026 it receives no releases and no security fixes. Existing installs still run, but they are no longer safe to leave in place long term.
  4. The Ingress API is feature-frozen, not removed.
  5. Gateway API replaces it with role-oriented resources: GatewayClass for the provider, Gateway for the platform team, and routes such as HTTPRoute for application teams.
  6. Rewrites, redirects, header matching, and weighted traffic splitting are typed fields in Gateway API.
  7. Every Ingress concept maps to a Gateway API resource, so migration starts with an audit of classes, Ingresses, and annotations.

In the next article you will install Envoy Gateway, create your first Gateway and HTTPRoute, and send real traffic to kiosk-api through them.