Use Gateway API rather than Ingress
Status: Accepted Date: 2026-08-21 Deciders: Smana (Platform Owner) Related Design: N/A — records a choice predating the design workflow Related: ADR-0009 — Cilium is the GatewayClass implementation this decision routes through
Context
Every request into this platform, public or private, goes through Gateway
API — GatewayClass, Gateway, HTTPRoute — never a Kubernetes
Ingress. There is no kind: Ingress manifest anywhere in this
repository, and Cilium’s own Ingress controller
(ingressController.enabled) is not turned on in
opentofu/eks/init/helm_values/cilium.yaml; only gatewayAPI.enabled: true is set. The one literal ingress-nginx reference left in the repo
is a subchart toggle explicitly set to enabled: false in
observability/base/grafana-oncall/helmrelease-oncall.yaml, and Grafana
OnCall itself is wired into no Kustomization, so it does not run.
ADR-0009 already decided that Cilium
replaces the VPC CNI, kube-proxy, and the NetworkPolicy engine, and named
“implements the Gateway API GatewayClass this platform’s Tailscale and
public ingress already depend on” as one of its reasons. That ADR’s
Option 2 (ingress-nginx) was rejected mainly on CNI-consolidation
grounds — running a fifth component just for NetworkPolicy. This record
exists to state the routing-model choice on its own terms and name the
cost that choice carries by itself, independent of the CNI decision:
Gateway API’s version is not free to move independently of Cilium’s.
Three resource roles carry the model
(Gateway API):
GatewayClass selects the controller, Gateway owns listeners,
hostnames, and TLS, and HTTPRoute owns routing rules and attaches to a
Gateway via parentRefs. The platform authors every Gateway under
infrastructure/base/gapi/; applications own their HTTPRoute through
the App Crossplane claim. An Ingress has no equivalent split — one
object carries both concerns, disambiguated only by vendor-specific
annotations.
Decision Drivers
- Role separation. The platform should own listener/TLS/hostname
configuration; applications should own routing rules. Mixing both into
one
Ingressobject, disambiguated by annotations, does not offer that split. - One mechanism for both public and private ingress. The two
Tailscale-private Gateways (
platform-tailscale-general,platform-tailscale-admin) needloadBalancerClass: tailscaleon the Service Cilium creates for them — aCiliumGatewayClassConfigparametersRefon theGatewayClass. Reaching the same outcome throughIngresswould need a second mechanism, since Cilium’s own Ingress controller is not enabled here at all. - No new controller. ADR-0009 already
requires Cilium as the CNI and
CiliumNetworkPolicyengine; adopting Gateway API turns Cilium’s existinggatewayAPI.enabled: truesetting into the ingress mechanism too, rather than installing a separate controller alongside it. - Native ExternalDNS integration. ExternalDNS’s
gateway-httproutesource watchesHTTPRouteobjects directly (infrastructure/base/external-dns/helmrelease.yaml), so a new hostname needs no separate annotation convention.
Considered Options
Option 1: Gateway API with Cilium as the GatewayClass implementation
Two GatewayClasses back three Gateways. cilium is created
automatically once gatewayAPI.enabled: true is set in the Cilium Helm
values (no manifest for it in this repository); cilium-tailscale is
hand-authored in infrastructure/base/gapi/tailscale-gatewayclass.yaml
with a parametersRef to a CiliumGatewayClassConfig. platform-public
uses cilium; platform-tailscale-general and platform-tailscale-admin
use cilium-tailscale.
Pros:
- Platform-owned
Gateway, application-ownedHTTPRoute— no per-app vendor annotations to keep aligned with a shared object. - One CRD family covers both the internet-facing gateway and the two
Tailscale-private gateways; only the
GatewayClassdiffers. - Cilium already runs as the CNI and
NetworkPolicyengine (ADR-0009); turning ongatewayAPIadds no separate controller. - ExternalDNS’s
gateway-httproutesource watchesHTTPRoutenatively.
Cons:
- The Gateway API CRD version cannot move independently of the Cilium minor installed — see Consequences.
cilium-operatoronly detects new Gateway API CRDs at its own startup — see Consequences.- Smaller ecosystem than
Ingress: fewer worked examples, and some upstream charts still ship only aningress:values stanza with no Gateway API templates (observability/base/grafana-oncall/helmrelease-oncall.yamldisables bothingressand a bundledingress-nginxsubchart it ships with no alternative).
Option 2: ingress-nginx
The mature, widely deployed Ingress controller
ADR-0009 already considered and rejected
as its Option 2.
Pros (per ADR-0009’s Option 2):
- Fully community-supported, large install base.
Cons:
- Speaks
Ingress, notGatewayClass— does not satisfy this platform’s Gateway-API-only routing model without a shim (ADR-0009 Option 2). - No mechanism comparable to
CiliumGatewayClassConfig’sloadBalancerClass: tailscalefor the two private gateways; reaching them throughIngresswould need a second, ingress-nginx-specific path, not the one CRD family Option 1 uses for all three Gateways.
Option 3: AWS Load Balancer Controller Ingress
The AWS Load Balancer Controller does run in this repository
(infrastructure/base/aws-load-balancer-controller/helmrelease.yaml,
chart aws-load-balancer-controller), but not as an Ingress
controller: no manifest under infrastructure/, tooling/,
observability/, security/, or apps/ sets ingressClassName or an
alb.ingress.kubernetes.io/* annotation, and no kind: Ingress object
exists anywhere in the repository. Its actual job is provisioning the AWS
NLB behind the one internet-facing Gateway, platform-public, whose
service.beta.kubernetes.io/aws-load-balancer-* annotations sit under
Gateway.spec.infrastructure.annotations
(infrastructure/base/gapi/platform-public-gateway.yaml) rather than on
an Ingress object.
Pros:
- Deep AWS integration for the load balancer itself (NLB target type,
scheme, naming) — already used today, just attached to a
Gatewayinstead of anIngress.
Cons:
- Running it as an
Ingresscontroller reintroduces the same role-separation loss as Option 2: listener and routing configuration merge into one object, disambiguated byalb.ingress.kubernetes.io/*annotations instead of Cilium/Tailscale ones. - Still no equivalent to
CiliumGatewayClassConfig’sloadBalancerClass: tailscalefor the two private gateways — AWS LBC provisions AWS load balancers, not Tailscale-backed Services, so the Tailscale gateways would still need a wholly separate mechanism.
Decision Outcome
Chosen option: “Option 1 — Gateway API with Cilium as the GatewayClass implementation”
Rationale: Cilium is already required for the CNI, kube-proxy
replacement, and NetworkPolicy engine
(ADR-0009), and it already ships a
GatewayClass implementation gated by one Helm value. Adopting Gateway
API is therefore not a new component, it is switching on a capability
Cilium already has, while Option 2 and Option 3 would each add one.
Gateway API’s role split also lets CiliumGatewayClassConfig carry the
loadBalancerClass: tailscale setting the two private Gateways depend
on, through the same GatewayClass/Gateway/HTTPRoute triple used for
the public Gateway — neither ingress-nginx nor AWS Load Balancer
Controller’s Ingress mode has a comparable path to that outcome without
a second, ingress-specific mechanism bolted on for the private case. The
cost is real and is not shared with the CNI decision: this platform’s
Gateway API CRD version is now pinned to what the installed Cilium minor
can run, not chosen freely.
Consequences
Positive
- The platform owns every
Gateway(infrastructure/base/gapi/); applications own theirHTTPRoutethrough theAppclaim — no vendor annotations on a shared object to keep in sync. - One CRD family serves all three Gateways:
platform-public(internet facing,cilium),platform-tailscale-generalandplatform-tailscale-admin(Tailscale-private,cilium-tailscale). - No separate ingress controller to operate — Cilium implements
GatewayClassas part of the component ADR-0009 already requires. - ExternalDNS’s
gateway-httproutesource (infrastructure/base/external-dns/helmrelease.yaml) watchesHTTPRoutedirectly and, withpolicy: sync, removes Route53 records when a route is deleted — no manual DNS step.
Negative
- Version lockstep with Cilium. Cilium is the
GatewayClassimplementation, so the Gateway API CRD version cannot move independently of it: Cilium ≤1.19.4 crashes on Gateway API ≥v1.5.0 (TLSRoute-v1, cilium#45139, fixed in 1.19.5).gateway_api_versioninopentofu/eks/configure/variables.tf(defaultv1.6.1) must equal the tagflux/sources/gitrepo-gateway-api.yamlpins Flux’sGitRepositoryto — both currentlyv1.6.1— and the installedcilium_version(opentofu/config.tm.hcl, currently1.20.0) must stay at or above the 1.19.5 floor.- Mitigation: none automated; the two pins and the Cilium floor are a manual check on every upgrade of either component, not something CI verifies today.
- CRDs must exist before
cilium-operatorstarts. It probes for the Gateway API CRDs exactly once, at startup, and permanently disables its Gateway API controller for the process lifetime if any is missing — no crash, no alert. EveryGatewayClassthen sits atAccepted=Unknown, everyGatewaystays unprogrammed, everyHTTPRoutegets nostatus.parents, and anyAppclaim that owns a route is stuckREADY=False(CLAUDE.md, “Gateways stuckWaiting for controller”). This already happened once: thebackendtlspoliciesCRD comment inopentofu/eks/configure/locals.tfrecords that its absence “is what broke Gateway API on the 2026-08-19 rebuild.”- Mitigation:
kubectl rollout restart -n kube-system deployment/cilium-operatorreruns the probe immediately; durably, the CRD’s URL is added to the append-onlygateway_api_crds_urlsinopentofu/eks/configure/locals.tfso it is present before the next rebuild’s probe runs.
- Mitigation:
- Smaller ecosystem than
Ingress. Fewer worked examples exist for Gateway API than forIngress, and some upstream charts still ship only aningress:values stanza with no Gateway API template —observability/base/grafana-oncall/helmrelease-oncall.yamlturns off both aningress:stanza and a bundledingress-nginxsubchart the chart offers as its only routing option.- Mitigation: none beyond authoring a standalone
HTTPRoutenext to such a chart, astooling/base/homepage/httproute.yamlalready does.
- Mitigation: none beyond authoring a standalone
Neutral
- Moving from
Ingressto Gateway API did not remove the AWS Load Balancer Controller from the platform.platform-publicstill gets its internet-facing NLB fromaws-load-balancer-controller(infrastructure/base/aws-load-balancer-controller/helmrelease.yaml); only the object carrying the AWS annotations changed, from anIngresstoGateway.spec.infrastructure.annotations.
Implementation Notes
infrastructure/base/gapi/ holds every Gateway-related manifest:
tailscale-gatewayclass.yaml (the cilium-tailscale GatewayClass),
tailscale-gatewayclass-config.yaml (the CiliumGatewayClassConfig
setting service.type: LoadBalancer, loadBalancerClass: tailscale, and
JSON Envoy access logs via spec.telemetry.accessLogs), and the three
Gateway manifests. platform-public-gateway.yaml restricts
allowedRoutes to the runlore namespace and carries the AWS NLB
annotations; the two Tailscale Gateways restrict allowedRoutes to a
namespace allowlist that has to be kept in sync by hand.
Cilium’s Gateway API support is turned on entirely through Helm values in
opentofu/eks/init/helm_values/cilium.yaml: gatewayAPI.enabled: true
and envoy.enabled: true (the L7 proxy). ingressController is not set,
so Cilium’s own Ingress support stays off.
The Gateway API CRDs themselves are not chart-managed: flux/sources/gitrepo-gateway-api.yaml
pins a GitRepository to kubernetes-sigs/gateway-api tag v1.6.1, and
opentofu/eks/configure/locals.tf’s gateway_api_crds_urls list installs
the same-versioned experimental CRDs (including backendtlspolicies and
listenersets, both required by Cilium ≥1.20) before Cilium’s Stage 2
install, so cilium-operator’s startup probe finds them.
References
- Gateway API — the resource model, the three Gateways, TLS, and ExternalDNS integration this ADR summarizes
- ADR-0009 — Cilium as the CNI and
GatewayClassimplementation this decision depends on - CLAUDE.md
— “Gateways stuck
Waiting for controller” troubleshooting entry opentofu/eks/configure/locals.tf—gateway_api_crds_urls, the append-only CRD list and thebackendtlspoliciesincident commentopentofu/eks/configure/variables.tf—gateway_api_versionflux/sources/gitrepo-gateway-api.yaml— the FluxGitRepositorypin that must matchgateway_api_versionopentofu/eks/init/helm_values/cilium.yaml—gatewayAPI.enabled,envoy.enabledinfrastructure/base/gapi/— everyGatewayClass,CiliumGatewayClassConfig, andGatewaymanifestinfrastructure/base/external-dns/helmrelease.yaml— thegateway-httproutesource andpolicy: sync- cilium#45139 — the Cilium ≤1.19.4 crash on Gateway API ≥v1.5.0 behind the version lockstep