Skip to content

Gateway API Three Ways, and What It Actually Buys Over Ingress

I ran the same routing problem three times: NGINX Gateway Fabric on a bare metal cluster, the AWS Load Balancer Controller on EKS, and an ALB Ingress Group sharing one load balancer across three services. Two used Gateway API and one used Ingress, and the differences between them were not the ones I expected.

The Thing Both Get Right, That People Get Wrong

Neither Ingress nor Gateway API routes on DNS. Both route on the HTTP Host header.

DNS gets a client to an IP address and its job ends there. The request that arrives carries Host: java-monolith.ibtisam-iq.com, and that header is what the routing layer matches. The two are entirely decoupled.

This matters practically, not academically. It means you can test routing before DNS exists:

curl -H "Host: java-monolith.ibtisam-iq.com" http://<node-ip>:<nodeport>

If that returns your application, routing works and any remaining failure is DNS. If it does not, no DNS record will save you. Separating those two questions eliminates most of the "the site is down" investigations I have run.

The Resource Model Is the Real Difference

Ingress is one object holding everything: hostnames, paths, TLS, backends, and whatever the controller needs expressed as annotations.

Gateway API splits it:

GatewayClass    <- what implementation (cluster operator)
  Gateway       <- listeners, ports, TLS certs (infrastructure owner)
    HTTPRoute   <- hostnames, paths, backends (application team)

The split is along ownership lines, and that is the actual argument for it. In a single-team cluster it is more YAML for the same result. In a cluster where one team owns the load balancer and another owns an application, it means the application team can add a route without touching the object that holds the certificate and the listener config.

Ingress Gateway API
Header matching annotations native
Traffic splitting annotations native
Cross-namespace refs no yes, via ReferenceGrant
TCP and UDP no yes
Resource model one object Gateway plus Routes
Role separation limited explicit

"Via annotations" is doing a lot of work in that table. Annotations are controller-specific strings, so a header-matching rule written for ingress-nginx is not portable to any other controller. Gateway API moves those into typed fields, which is why the same HTTPRoute renders on NGF and on the AWS controller.

Implementation 1: NGINX Gateway Fabric on Bare Metal

No cloud to provision anything, so everything is explicit.

NGF creates a separate nginx deployment and Service for each Gateway, in that Gateway's namespace. The controller in nginx-gateway stays ClusterIP permanently and is not the data plane. That distinction cost me an afternoon and is the single most useful thing to know about NGF.

Because there is no cloud load balancer, the per-Gateway Service is a NodePort and something has to connect host port 80 to it. TLS comes from cert-manager solving an ACME challenge inside the cluster.

The detail that surprised me: the HTTP to HTTPS redirect has to be written into the HTTPRoute. There is nothing else in the path to do it.

- matches:
    - path: { type: PathPrefix, value: / }
  filters:
    - type: RequestRedirect
      requestRedirect: { scheme: https, statusCode: 301 }

Implementation 2: AWS Load Balancer Controller on EKS

Same Gateway API resources, completely different mechanics underneath.

The controller watches Gateway objects and provisions a real ALB. TLS terminates at that ALB using an ACM certificate referenced through a LoadBalancerConfiguration, so cert-manager is not involved at all. ExternalDNS watches the same resources and writes the Route 53 records.

And the redirect is gone from the HTTPRoute, because the ALB handles it at the listener.

That is why the bare metal and EKS overlays cannot share an HTTPRoute. It is not cosmetic drift. A redirect filter that is mandatory in one environment is redundant in the other, and shipping one manifest to both means shipping something wrong to one of them.

The ALB controller injects defaults, and ArgoCD notices

After the first sync, ArgoCD reported the HTTPRoute permanently OutOfSync. The live object had group, kind, weight and matches fields the Git manifest did not, all injected by the controller as Gateway API defaults.

The fix is to write them explicitly in the manifest so the desired state matches what the controller will produce. Otherwise every sync reports drift that is not drift, and a real change gets lost in the noise of a diff that is always non-empty.

Implementation 3: One ALB, Three Services, via Ingress

The retail deployment used Ingress rather than Gateway API, and got something Gateway API would have made harder.

alb.ingress.kubernetes.io/group.name: ecom-eks

Three separate Ingress resources, in three namespaces, for the UI, Prometheus and Grafana. That annotation makes the controller merge them onto one ALB, routing by Host header.

The alternative is three ALBs. That is three sets of hourly charges, three DNS records to manage, and three certificates or one wildcard attached three times, for what is architecturally one entry point.

Gateway API expresses this differently: one Gateway, three HTTPRoutes attached to it, potentially from three namespaces via ReferenceGrant. Arguably cleaner. But group.name is one annotation on resources the teams already own, and the Gateway version requires someone to own the shared Gateway object and grant access to it. For three services in one cluster, the annotation won on effort.

What Actually Differed

Across all three, the application manifests were identical. Everything that changed lived in the routing layer:

Bare metal, NGF EKS, AWS LBC EKS, ALB Ingress
API Gateway API Gateway API Ingress
Data plane in-cluster nginx ALB ALB
Exposure NodePort plus iptables controller-provisioned controller-provisioned
TLS terminates in cluster at the ALB at the ALB
Certificate from cert-manager, ACME ACM ACM
HTTP to HTTPS HTTPRoute filter ALB listener ALB listener
DNS manual ExternalDNS manual
Sharing one Gateway one Gateway group.name

The pattern: the more the cloud provides, the less appears in your manifests. The bare metal column is the longest because nothing was done for me, which also makes it the one where I learned what the other two were quietly handling.

Where the IP Comes From

The question underneath all three implementations, and the one that separates bare metal from cloud more than any API choice: who assigns the address clients connect to?

On a cloud provider, a Service of type LoadBalancer or a controller watching Gateway objects calls the provider's API, gets a load balancer, and writes its hostname back into the resource status. The address arrives on its own.

On bare metal there is no such API. A LoadBalancer Service stays <pending> forever, which is not a bug and not a misconfiguration; nothing in the cluster is able to allocate an external address. The options are NodePort plus something in front of it, hostNetwork binding the proxy to the node directly, or MetalLB to hand out addresses from a pool you own.

That is why the bare metal column needed iptables and the cloud columns did not. The API was identical. The thing that differed was whether an address existed to be routed to.

kubectl get svc -n <namespace>
# EXTERNAL-IP <pending> on bare metal is the expected state, not a failure

Recognising <pending> as "nothing here can allocate one" rather than "it is still working on it" saves an hour the first time.

When Ingress Is Still Right

Gateway API is the direction of travel and I would start there on a new cluster. That is not the same as migrating everything.

Ingress remains the better choice when the routing is genuinely simple host and path, when your controller's ecosystem and existing CI integrations assume it, and when the team already reads Ingress fluently. A three-object model to express what one object expressed is a real cost paid on every read, and it only pays back where the extra structure is used.

The honest test: are you using cross-namespace routes, native header matching, traffic splitting, or TCP routing? If none of those, Gateway API is giving you role separation and future-proofing, which are legitimate reasons and are not urgent ones.

The Debugging Order That Works

The layering is the same in all three implementations, so the diagnostic order is too:

  1. Does the Service work? kubectl port-forward straight to it. If this fails, routing is irrelevant.
  2. Does the data plane route it? curl with an explicit Host header at the NodePort or the load balancer DNS name. This is where a wrong ingressClassName or an unattached parentRef shows up.
  3. Does the route attach? kubectl describe gateway and look at attachedRoutes. A route that attached to nothing is the most common Gateway API failure and it reports as a status condition, not an error.
  4. Does DNS resolve? Last, because everything above is testable without it.

Every step is independent of the one after it, which is what makes the order worth following rather than jumping to whichever layer you know best.

Source


Related