How Gateway API replaces the Ingress resource model

An Ingress object bundles everything into one resource: the hostname, TLS secret, path rules, backend services, and through annotations, controller-specific behavior. The IngressClass only says which controller handles it. Gateway API splits that bundle into three resources, each matched to the team that should own it.

  • GatewayClass identifies the infrastructure provider, similar to IngressClass. It is usually installed once per cluster by whoever runs the controller.
  • Gateway declares listeners: the ports, protocols, hostnames, and TLS certificates the load balancer exposes. The platform team owns it.
  • HTTPRoute holds the routing rules: host and path matches, header matches, and weighted backends. Application teams own it in their own namespaces.

A route attaches to a Gateway through parentRefs, which can name a specific listener with sectionName. Attachment is a two-way agreement. The route asks to attach, and the listener's allowedRoutes decides whether it accepts. allowedRoutes can permit routes from the same namespace (the default), from all namespaces, or from namespaces that match a label selector. A route outside that scope does not attach, and its status conditions report why.

This split is the main reason to migrate. With ingress-nginx, any team that could edit an Ingress could also set annotations that changed shared proxy behavior. Snippet annotations went further and injected raw NGINX configuration, which has been a recurring security problem. Gateway API puts that control in RBAC. App teams get write access to HTTPRoutes and no access to Gateways or the listener settings that affect everyone.

Your existing hosts, paths, and backends map onto these resources almost directly. Treat the migration as a translation of your current configuration, not a redesign.

  INGRESS-NGINX MODEL           GATEWAY API MODEL
+--------------------------+    +---------------------------------------+
| IngressClass             |--->| GatewayClass   (cluster admin)        |
|  which controller        |    |  infrastructure provider              |
+--------------------------+    +-------------------+-------------------+
                                                    ^ gatewayClassName
+--------------------------+    +-------------------+-------------------+
| Ingress (one object)     |    | Gateway (ns: infra)  owner: platform  |
|  hostname, TLS secret ---+--->|  listener "https" :443, *.example.com |
|  paths -> backends ------+-+  |  TLS certificateRefs                  |
|  annotations, snippets   | |  |  allowedRoutes.namespaces:            |
|   (any editor can alter  | |  |    from: Selector  team-routes=true   |
|    shared proxy config)  | |  +-------------------^-------------------+
+--------------------------+ |                      |
                             |                      | parentRefs
                             |                      | sectionName: https
                             |          +-----------+-----------+
                             |          |                       |
                             |   +------+-----------+    +------+-----------+
                             +-->| HTTPRoute        |    | HTTPRoute        |
                                 |  ns: shop        |    |  ns: sandbox     |
                                 |  label matches   |    |  no label        |
                                 |  => Accepted     |    |  => not attached |
                                 +--------+---------+    +------------------+
                                          |               (status says why)
                                          v
                                 +------------------+
                                 | Service backends |
                                 +------------------+
RBAC: app teams write HTTPRoutes; only the platform team edits Gateways.

An Ingress and its IngressClass split into a GatewayClass, a platform-owned Gateway whose listener allowedRoutes selects namespaces, and app-owned HTTPRoutes that attach through parentRefs.

How it works: from listener to backend

Gateway API is a specification. The Kubernetes API server only stores and validates the resources. A controller does the actual work, much as ingress-nginx does for Ingress objects.

  1. Reconciliation. The controller watches for GatewayClasses whose controllerName it owns. It then watches Gateways that reference those classes. For each Gateway it provisions or configures a data plane, such as a proxy Deployment or a cloud load balancer, and opens the declared listeners. It reports the result in the Gateway's status, including assigned addresses and per-listener conditions.
  2. Route attachment. The controller evaluates every HTTPRoute whose parentRefs point at the Gateway. It checks the listener's allowedRoutes, the listener protocol, and whether the route's hostnames intersect the listener's hostname. Routes that pass are merged into the listener's routing table. Each parent gets its own Accepted and ResolvedRefs conditions.
  3. Request handling. A request arrives on a listener selected by port and, for HTTPS, by SNI hostname. The data plane picks the attached routes whose hostnames match the Host header. Among their rules, it chooses the most specific match according to the precedence rules in the spec.
  4. Filters and backends. The matched rule applies its filters in order, for example header modifiers, URL rewrites or a redirect. A redirect ends processing at that point. Otherwise the request goes to one of the rule's backendRefs, chosen in proportion to the weights. The backend is resolved to the Service's endpoints.

This flow differs from ingress-nginx in one important way. ingress-nginx renders every Ingress in the cluster into a single nginx.conf, and its annotations tune that shared file. Gateway API keeps listener settings, routing rules and backend references in separate objects with separate status. When something is wrong, you can see which object rejected it.

Mapping the core Ingress spec and TLS to HTTPRoute

A table works well for the field-by-field mapping:

Ingress field Gateway API equivalent
spec.ingressClassName parentRefs on the HTTPRoute, naming a Gateway
rules[].host spec.hostnames
paths[].path with pathType: Prefix matches[].path with type: PathPrefix
paths[].path with pathType: Exact matches[].path with type: Exact
backend.service.name and port.number backendRefs[].name and port

Both specs match prefixes by path element, so /api matches /api/v1 but not /apiv1. Your existing prefix rules keep their behavior.

The class reference changes most. ingressClassName selects a controller. A parentRef attaches the route to a specific Gateway that a platform team owns. That Gateway can be shared across namespaces and restricted to the routes it allows.

pathType: ImplementationSpecific has no portable equivalent. This includes regex paths enabled with nginx.ingress.kubernetes.io/use-regex. HTTPRoute defines a RegularExpression match type, but each implementation chooses its own regex dialect and whether to support it. Rewrite these rules as prefix or exact matches where you can.

TLS and HTTPS redirects

tls.secretName moves off the route. It becomes an entry in certificateRefs on an HTTPS listener of the Gateway. The listener terminates TLS, and the route only handles routing.

If the Secret is in a different namespace from the Gateway, create a ReferenceGrant in the Secret's namespace. The grant allows Gateways from the other namespace to read the Secret. Without it, the reference is rejected, so a Secret owner must opt in explicitly.

ssl-redirect becomes two pieces:

  • An HTTP listener on port 80.
  • A small HTTPRoute attached to that listener, with a RequestRedirect filter that sets scheme: https.

Attach your main route to the HTTPS listener only, using sectionName in its parentRef. Otherwise the main route also serves plain HTTP and bypasses the redirect.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: shared-gw, namespace: infra}
spec:
  gatewayClassName: example-gateway-class   # placeholder; use your controller's class
  listeners:
  - {name: http, protocol: HTTP, port: 80}
  - name: https
    protocol: HTTPS
    port: 443
    hostname: shop.example.com
    tls:
      mode: Terminate
      certificateRefs:                     # replaces Ingress tls.secretName
      - {kind: Secret, name: shop-tls, namespace: certs}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: https-redirect, namespace: infra}
spec:
  parentRefs: [{name: shared-gw, sectionName: http}]
  hostnames: [shop.example.com]
  rules:
  - filters:
    - type: RequestRedirect
      requestRedirect: {scheme: https, statusCode: 301}
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata: {name: allow-infra-gateways, namespace: certs}
spec:
  from:
  - {group: gateway.networking.k8s.io, kind: Gateway, namespace: infra}
  to:
  - {group: "", kind: Secret, name: shop-tls}

A Gateway with an HTTPS listener that references a Secret in another namespace through a ReferenceGrant, and an HTTPRoute bound to the HTTP listener that redirects to https, which replaces ssl-redirect.

Translating nginx.ingress.kubernetes.io annotations

Most of the migration effort is in the annotations, because the Ingress spec never standardized them. Sort each annotation you use into one of three groups before you write any YAML.

Outcome Ingress NGINX annotation Gateway API equivalent
Mapped to standard features rewrite-target URLRewrite filter with ReplacePrefixMatch or ReplaceFullPath
permanent-redirect RequestRedirect filter
Request/response header changes RequestHeaderModifier, ResponseHeaderModifier
canary-weight Weighted backendRefs in one rule
canary-by-header Separate rule with a header matches entry
Implementation-specific or policy-attached Rate limiting, external auth, CORS, timeouts, body-size limits Varies by implementation and release channel
No equivalent configuration-snippet, server-snippet, Lua None

The mapped group is mostly mechanical, with one exception. URLRewrite has no capture-group regex, so a rewrite-target: /$2 pattern only translates cleanly if it is really a prefix swap. Rewrites that reorder path segments have to be simplified, or the work has to move to the backend.

For the middle group, check your chosen implementation's documentation for each annotation. Some of these features are standard fields, some are experimental, and some exist only as vendor policy resources.

Snippets and Lua are arbitrary NGINX config, so nothing translates them. Decide each one on purpose: redesign it with standard filters, move it to a vendor policy CRD, move the logic into the application, or remove it and record why. Each vendor CRD you adopt ties that route to one implementation. That is often an acceptable cost, but you should know where it applies to each route.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: shop
  namespace: shop
spec:
  parentRefs:
    - name: public-gateway
      namespace: gateway-system
  hostnames:
    - shop.example.com
  rules:
    # Replaces rewrite-target: /$2 on path /api(/|$)(.*)
    # Only works because the original regex was a plain prefix swap.
    - matches:
        - path:
            type: PathPrefix
            value: /api
      filters:
        - type: URLRewrite
          urlRewrite:
            path:
              type: ReplacePrefixMatch
              replacePrefixMatch: /
      backendRefs:
        - name: shop-api
          port: 8080
    # Replaces canary: "true" + canary-weight: "10"
    # Weights are relative: 90/10 sends about 10% of requests to v2.
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: shop-web-v1
          port: 80
          weight: 90
        - name: shop-web-v2
          port: 80
          weight: 10

An HTTPRoute that translates a prefix-style rewrite-target into a URLRewrite filter with ReplacePrefixMatch, and a canary-weight annotation into weighted backendRefs in a single rule.

Using ingress2gateway as a starting point

Translating every Ingress by hand is slow and error-prone once you have more than a handful. ingress2gateway, a tool from the Kubernetes SIGs community, automates the mechanical part. It reads Ingress resources, interprets the annotations for the providers it supports (ingress-nginx among them), and emits Gateway and HTTPRoute manifests. Hosts, paths, backends and TLS references usually come through cleanly, so you don't have to retype that part.

Treat the output as a draft. Annotation coverage is partial and changes between releases. Depending on the version, an unsupported annotation may be dropped with a warning or dropped silently. Either way, behavior such as rate limiting, auth subrequests or custom snippets can disappear without any change in the generated YAML. If you apply the output blindly, routes may return 200 while doing less than they did before. Check the documentation for the version you have installed to see which annotations it actually translates.

A review workflow

  1. Export the source of truth. Dump every Ingress with kubectl get ingress -A -o yaml and commit the output. You will diff against this file and roll back to it if needed.
  2. Generate. Run ingress2gateway against the export or the live cluster, and save the tool's warnings along with the manifests.
  3. Inventory annotations. List every distinct nginx.ingress.kubernetes.io/* key in the export. Mark each one as translated, mapped manually or deliberately dropped.
  4. Diff behavior, not just YAML. For each route, compare matches, rewrites, redirects, header changes and timeouts against the original annotations. Any annotation that has no counterpart in the output is a gap you still need to resolve.
#!/usr/bin/env bash
set -euo pipefail

WORKDIR=migration
mkdir -p "$WORKDIR"

kubectl get ingress -A -o yaml > "$WORKDIR/ingress-export.yaml"

ingress2gateway print \
  --providers=ingress-nginx \
  --input-file="$WORKDIR/ingress-export.yaml" \
  > "$WORKDIR/gateway-draft.yaml" \
  2> "$WORKDIR/ingress2gateway-warnings.txt"

grep -oE 'nginx\.ingress\.kubernetes\.io/[A-Za-z0-9-]+' \
  "$WORKDIR/ingress-export.yaml" \
  | sort | uniq -c | sort -rn \
  > "$WORKDIR/annotation-inventory.txt"

while read -r _count key; do
  if grep -qF "$key" "$WORKDIR/ingress2gateway-warnings.txt"; then
    echo "WARNED   $key"
  else
    echo "VERIFY   $key  (no warning, so check the HTTPRoute by hand)"
  fi
done < "$WORKDIR/annotation-inventory.txt"

A shell workflow that exports existing Ingresses, generates draft Gateway API manifests with ingress2gateway while keeping its warnings, and lists every ingress-nginx annotation that still needs a manual behavior review.

Check feature maturity before you commit

Gateway API ships its CRDs in two release channels. The Standard channel contains resources and fields that have graduated and carry backward-compatibility guarantees. The Experimental channel is a superset: it adds resources and fields that are still being designed. Experimental items can be renamed, reshaped or removed in a later release, so manifests that use them may stop validating after an upgrade.

The spec also assigns each feature a support level. Core features must work on every conformant implementation. Extended features are optional, so a controller may or may not implement them. Implementation-specific behavior is left to each controller. This means a Standard-channel field marked Extended can still be unsupported by the controller you run. Holding to the spec does not guarantee that your controller supports the feature.

For every feature your migrated routes depend on, check your controller's own documentation and published conformance results. Typical examples are path rewrites, request mirroring, timeouts, header modifiers and cross-namespace references. Do not infer support from the spec alone or from another controller's results. Check the features you actually use, not the product as a whole.

If you depend on anything Experimental, plan for that ongoing cost:

  • CRD upgrades are cluster-wide. One set of Gateway API CRDs serves every controller in the cluster. A managed platform or a second controller may also want to own them. Decide who installs and upgrades them.
  • Pin the CRD release your controller supports, and upgrade CRDs and controller together.
  • Read release notes for changes to Experimental fields, and budget time to rewrite manifests when a field changes or graduates.

A safe cutover sequence with parity testing

Step-by-step plan

  1. Run both stacks side by side. Install the Gateway controller next to ingress-nginx and give each Gateway its own external address. Neither controller then claims the other's traffic, and you can compare them directly.
  2. Apply HTTPRoutes and read their status. Run kubectl get httproute and kubectl describe httproute, then check the Accepted and ResolvedRefs conditions under each parent. Accepted: False usually means the route did not attach to its listener, for example because of a hostname or namespace mismatch. ResolvedRefs: False points to a missing Service, a wrong port, or a cross-namespace reference that no ReferenceGrant allows.
  3. Test before touching DNS. Use curl --resolve host:443:<new-ip> so SNI and certificate checks behave as they will in production. A plain Host header is enough for HTTP-only checks.
  4. Shift traffic gradually. Lower the DNS TTL well before the move, or use a weighted load balancer in front of both addresses. Keep the old Ingress objects until you no longer need a rollback.

Parity request matrix

Run the same requests against both entry points and compare status codes, response headers and bodies. Cover:

  • every host
  • paths with and without trailing slashes
  • redirects and rewrites
  • header manipulation
  • uploads near your body-size limits
  • WebSocket upgrades
  • gRPC

Precedence differences

Path precedence is a common source of mismatches. Gateway API orders matches by explicit rules: Exact beats PathPrefix, a longer prefix wins, and further tie-breakers follow. With use-regex, ingress-nginx renders regex locations, and nginx picks the first regex that matches. The same path set can therefore route a request to a different backend under each controller.

Debugging 404s

A 404 now has more possible causes. It can come from the Gateway listener, the route's attachment to that listener, the hostname intersection between the two, the match rules, or the backend reference. Check each object's status in that order.

When to migrate, when to wait, and the cost of each path

Migrate sooner if several application teams share one controller. Shared annotations and a single ConfigMap make every change a cross-team negotiation. Gateway API separates the Gateway, owned by the platform team, from the HTTPRoutes owned by each app team. Migrate sooner as well if you allow configuration-snippet, because one tenant's raw NGINX config can affect every route on the controller. If the maintenance status of the controller you run is unclear, check the project's own announcements and plan around them.

Defer if you have a small cluster with a few simple host and path Ingresses, because the separate ownership model gives you little in that case. Also defer if your routing depends on snippets or Lua, since you would be redesigning that behaviour rather than translating it.

Path Up-front cost Long-term trade-off
Stay on current controller None Annotation sprawl and support risk remain
Another Ingress controller Annotation rewrite Still tied to annotations, now a different dialect
Vendor-specific CRD Rewrite to a proprietary schema Lock-in to one implementation
Gateway API Redesign, parallel run, observability rework Portable spec with clear ownership boundaries

Running controllers in parallel means extra load balancers and addresses, plus split logs and metrics. Dashboards, log parsers and alerts that key on NGINX log formats need rework.

Illustrative estimate (assumed numbers, not a benchmark)

Effort = (Ingresses × hours per route) + (custom annotations × redesign hours) + observability rework + parallel-run weeks × weekly ops hours

Assumed inputs: 60 Ingresses at 1 h each, 15 custom annotations at 4 h each, 40 h of observability rework, and 4 weeks of parallel running at 6 h per week. That gives 60 + 60 + 40 + 24 = 184 engineer-hours.

Weigh this against the hours each quarter currently spent reviewing shared annotations and handling cross-team ingress tickets.

When to avoid migrating to Gateway API

Some situations call for postponing the migration or skipping it, not just deferring it for convenience:

  • Your controller lacks a feature you rely on. If the Gateway implementation you would run does not support a behavior your routes need, the migration loses functionality. Check its documentation and conformance results first. Common gaps are rewrites, timeouts, external auth and body-size limits. Don't plan on it shipping later.
  • Critical behavior lives in snippets or Lua and nobody owns the redesign. Translating that logic means rewriting it. If no team has time to rewrite it and test it, the migration will stall halfway or quietly drop behavior.
  • You expose raw TCP or UDP services through ingress-nginx. ingress-nginx handles these through its TCP and UDP services ConfigMaps. Gateway API covers them with TCPRoute and UDPRoute. Check which channel those resources are in for your CRD release and whether your controller supports them. If they are only Experimental for you, that traffic carries the upgrade risk described above.
  • You cannot control the Gateway API CRDs. On some managed platforms the provider installs and upgrades the CRDs. If that version conflicts with what your controller needs, resolve the ownership question before you migrate production traffic.
  • You cannot run a parallel stack. Without spare addresses, a second load balancer and time for parity testing, the cutover becomes an in-place switch with weak rollback. Wait until you can run both side by side.
  • The setup is small, stable and single-team. A few host and path Ingresses with no custom annotations gain little from separate ownership. The migration cost may never pay back.

Alternatives to Gateway API

The cost table above lists the options. Each one makes sense in different conditions:

  • Stay on ingress-nginx. This is the lowest-effort option if your configuration is simple and the project's maintenance status, as stated in its own announcements, still fits your support needs. Lock down annotation use to reduce the risk. Disable snippet annotations unless you need them, and restrict who can edit Ingress objects with RBAC and admission policy.
  • Switch to another Ingress controller. You keep the Ingress resource and your existing tooling. The nginx.ingress.kubernetes.io/* annotations still have to be rewritten into the new controller's annotation set, and the ownership problem stays the same, because annotations remain on the shared Ingress object.
  • Adopt a controller's own routing CRDs. These often expose more features than either Ingress or the Gateway API Standard channel. The trade-off is lock-in: moving off that controller later means a second migration.
  • Use a cloud load balancer's Ingress integration. If a cloud controller already provisions your load balancers from Ingress objects, routing can stay there. The feature set is whatever that integration supports, and its annotations are just as non-portable.
  • Gateway API with vendor policies for the gaps. This is the middle path. Standard HTTPRoutes carry most routing, and a small number of vendor policy resources cover rate limiting or auth. The lock-in stays confined to the routes that use those policies.

How the migration saves engineering time

The up-front cost is real. The time savings come afterwards, from several sources:

  • Fewer cross-team reviews. App teams change their own HTTPRoutes in their own namespaces. The listener settings that affect everyone sit on a Gateway they cannot edit, so routine route changes no longer need platform review to guard against shared-config side effects.
  • Faster debugging. Accepted and ResolvedRefs conditions, plus per-listener status on the Gateway, show which object rejected a configuration and why. With ingress-nginx, the same diagnosis often means reading the rendered nginx.conf or controller logs.
  • No annotation archaeology. Rewrites, redirects, header changes and traffic splits are typed fields that the API server validates. Engineers no longer need to look up annotation names and string formats, and typos are caught at apply time rather than being silently ignored.
  • Portability. Routes that use only Core and supported Extended features can move to another conformant implementation with far less rework than an annotation-based setup.
  • Automated first drafts. ingress2gateway and the review workflow above turn most of the translation into checking generated output, not writing it by hand.

Illustrative estimate (assumed numbers, not a measured result)

Assumed inputs: 25 engineer-hours per quarter spent reviewing shared annotation changes and handling cross-team ingress tickets before the migration, and 8 hours per quarter afterwards. The saving is 17 hours per quarter. Against the assumed 184-hour migration from the earlier estimate, that pays back in about 184 ÷ 17 ≈ 11 quarters. If your current coordination overhead is higher, payback is faster. If it is lower, the time savings alone may not justify the migration, and the security and ownership benefits have to carry the decision.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to ingress nginx to gateway api migration, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.