Skip to main content
BlogDevOps

Ingress2Gateway 1.0: Migrate ingress-nginx to Gateway API

A close reading of Ingress2Gateway 1.0 for ingress-nginx migrations: the commands, the warnings it raises, what it cannot translate, and a cutover that can be undone.

Matthews Wong

Software Engineer

October 202612 min read
Ingress2Gateway 1.0: Migrate ingress-nginx to Gateway API

Key Takeaway

Ingress2Gateway 1.0, the official SIG Network migration assistant, converts over 30 ingress-nginx annotations to Gateway API and tests each one against real controllers. It still cannot translate configuration snippets, body size limits or URL normalization, so every run needs a reviewed output, a dev cluster test and a gradual traffic shift before Ingress is deleted.

The migration most teams dread is not the first HTTPRoute. It is the annotation nobody remembers adding, three years ago, that quietly changes how one path behaves. ingress-nginx accumulated those through annotations, ConfigMaps and snippets, and Gateway API has no place to copy them to. Ingress2Gateway 1.0 is the tool the Kubernetes project built to find them for you.

This is a close reading of the 1.0 announcement, the repository README, the ingress-nginx provider documentation and the Kubernetes post on ingress-nginx behaviours that break migrations. I did not run it against a live cluster for this post, so every input, output and warning shown below is quoted from those sources, and every number traces to one. The walkthrough follows the order you would work in: install, run, read the warnings, fix by hand, cut over.

What 1.0 changed, and what it still is not

The headline change is coverage. Before 1.0, Ingress2Gateway supported three ingress-nginx annotations. The 1.0 release supports over 30 common ones, including CORS, backend TLS, regex matching and path rewrite. The second change matters more for trust: each supported annotation, and representative combinations of common annotations, is backed by integration tests that start a real ingress-nginx controller and several Gateway API controllers, apply the Ingress, translate it with the tool, apply the result, and compare runtime behaviour such as routing, redirects and rewrites. The release post is explicit that this compares behaviour, not YAML shape.

The architecture has two halves. Providers read Ingress resources and provider CRDs into an intermediate representation. Emitters turn that into output: the default standard emitter writes core Gateway and HTTPRoute resources, while others add resources for one implementation. The release post names agentgateway, envoy-gateway and kgateway. The README on the main branch, which I read on 2026-10-17 and which may be newer than the 1.0 tag, also lists airlock-microgateway and gce, and lists nine providers beyond ingress-nginx, such as istio, kong, traefik and nginx. v1.0 targets Gateway API v1.5.0, and the README says its output is generally forward-compatible with v1.4.

What it is not: a one-shot converter. The release post defines three jobs, to migrate supported configuration and behaviour, to identify unsupported configuration and suggest alternatives, and to let you reevaluate and discard configuration you no longer want. The README adds that it is not intended to copy annotations from Ingress to Gateway API. Treat the output as a first draft with a to-do list attached. The Gateway API project's own migration guide agrees on scope: it explains the concepts, shows a manual conversion and points to this tool for automatic conversion, but says it will not prepare you for a live migration.

Install it and run it three ways

Install with go install at the 1.0.0 tag or with Homebrew; the README also points to a releases page with binaries. The print command takes input three ways: manifest files, one namespace read live from the cluster, or every namespace. The required flag is --providers. Everything below is taken from the release post and the README flag table.

# Install: pin the tag, or use Homebrew
go install github.com/kubernetes-sigs/ingress2gateway@v1.0.0
brew install ingress2gateway

# 1. From manifest files (comma-separated)
ingress2gateway print --input-file my-manifest.yaml,my-other-manifest.yaml \
  --providers=ingress-nginx > gwapi.yaml

# 2. One namespace, read live from your current kubeconfig context
ingress2gateway print --namespace my-api --providers=ingress-nginx > gwapi.yaml

# 3. The whole cluster
ingress2gateway print --providers=ingress-nginx --all-namespaces > gwapi.yaml

# Optional: implementation-specific extras (agentgateway | envoy-gateway | kgateway)
ingress2gateway print --providers=ingress-nginx -A --emitter envoy-gateway > gwapi.yaml

# If your controller uses a different IngressClass, the default (nginx) selects nothing
ingress2gateway print --providers=ingress-nginx \
  --ingress-nginx-ingress-class=ingress-nginx -A > gwapi.yaml

Four flags are worth knowing before the first run. --output accepts yaml, json or kyaml. --ingress-nginx-ingress-class defaults to nginx, so a cluster using another class name silently selects nothing until you set it. --allow-experimental-gw-api includes experimental Gateway API fields such as URLRewrite in the output. And ordering is deterministic: the oldest Ingress by creation timestamp wins a conflict, ties break on namespace and name, and a later conflicting rule is reported as an error rather than merged.

A worked conversion: input, output and warnings

The release post walks one Ingress through the tool. It carries six annotations: proxy-body-size 1G, use-regex, proxy-send-timeout and proxy-read-timeout set to 1, enable-cors, and a configuration-snippet that sets a Request-Id header. The path is a regex, /users/(\d+), with ImplementationSpecific path type, and TLS is configured.

# Input: the Ingress used in the Kubernetes release post
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ingress
  namespace: my-ns
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "1G"
    nginx.ingress.kubernetes.io/use-regex: "true"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "1"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "1"
    nginx.ingress.kubernetes.io/enable-cors: "true"
    nginx.ingress.kubernetes.io/configuration-snippet: |
      more_set_headers "Request-Id: $req_id";
spec:
  ingressClassName: nginx
  rules:
    - host: my-host.example.com
      http:
        paths:
          - path: /users/(\d+)
            pathType: ImplementationSpecific
            backend:
              service:
                name: website-service
                port:
                  number: 80
  tls:
    - hosts: [my-host.example.com]
      secretName: my-secret

The generated manifest has a Gateway with an HTTP and an HTTPS listener, an HTTPRoute with a CORS filter, a RegularExpression path match and a request timeout, plus a second HTTPRoute that redirects port 80 to HTTPS with status 308. That second route mirrors ingress-nginx's default redirect for TLS hosts. Read the timeout line twice: both source annotations said 1, and the tool produced a 10 second request timeout, which the post calls a best-effort translation of TCP-level timeouts. The warnings are where the real information is.

# Output (excerpt): the HTTPRoute as first generated
rules:
- backendRefs:
  - name: website-service
    port: 80
  filters:
  - type: CORS
    cors:
      allowOrigins: ['*']
      maxAge: 1728000
  matches:
  - path:
      type: RegularExpression
      value: (?i)/users/(\d+).*      # prefix + case-insensitive, like ingress-nginx
  timeouts:
    request: 10s                      # input said 1 and 1: this is the tool's best effort
# plus a second HTTPRoute on port 80: RequestRedirect, scheme https, statusCode 308
Message from the toolWhat it meansThe decision it forces
WARN: unsupported annotation configuration-snippetRaw NGINX directives have no Gateway API field.Find an implementation-specific equivalent, or drop the behaviour.
INFO: case-insensitive regex path matchesThe output pattern is (?i)/users/ then a digits group then .* at the end.Keep it if clients vary case; otherwise strip both for an exact match.
WARN: best-effort timeouts.requestingress-nginx timeouts are TCP-level; the tool approximated them as 10s.Pick the request timeout you actually want, for example 3s.
WARN: failed to apply proxy-body-sizeNo standard Gateway API field; most implementations have defaults.Check your implementation's limit, or try an emitter extension.
WARN: URL normalization not configurableGateway API cannot configure RFC 3986 normalization; implementations differ.Test dot segments and double slashes on your target controller.

The tool's own recommendation for the HTTP listener is also a decision: it added port 80 and a redirect only to match ingress-nginx, and the post suggests you may prefer to remove both if you do not want to serve HTTP at all.

What it cannot translate, and what is half-translated

The ingress-nginx provider README separates annotations into converted and recognised-but-not-converted. The second group warns and produces nothing, which is the safe failure mode: you find out at review time. The table below is condensed from that README as read on 2026-10-17; it lists the families that decide whether a real cluster migrates cleanly.

Annotation familyConverted toCaveat in the README
canary, canary-weight, canary-by-headerBackend weights and an HTTPHeaderMatchcanary-by-header-pattern and canary-by-cookie are recognised but not converted
rewrite-target, app-rootURLRewrite with ReplaceFullPath; a 302 redirect for app-rootCapture group references such as $1 are not supported and are flagged
permanent-redirect, temporal-redirect, ssl-redirectHTTPRequestRedirect filters with selectable status codesproxy-redirect-from and proxy-redirect-to are recognised but not converted
upstream-vhost, connection-proxy-header, x-forwarded-prefixHTTPRequestHeaderModifier filterscustom-headers is recognised but not converted
proxy-connect, send and read timeoutsGateway API timeout configuration, value in secondsThe release post calls it best-effort and shows 1s inputs producing a 10s request timeout
backend-protocol, proxy-ssl-*GRPCRoute for GRPC and GRPCS; BackendTLSPolicy for HTTPS and GRPCSproxy-ssl-verify-depth and proxy-ssl-protocols are not converted; FCGI warns
whitelist-source-range, denylist-source-rangeListed as supported CIDR controlsThe README does not say which Gateway API object carries them; read the output
ssl-passthroughA TLSRoute plus a port 443 Passthrough listenerThe HTTPRoute for that Ingress is replaced, not supplemented

Two omissions deserve a name. configuration-snippet, the annotation the Kubernetes retirement notice cites as a serious security flaw, has no translation by design. And proxy-body-size is converted by the provider README to a Kubernetes quantity, yet the release post's own run reported that the standard emitter could not apply it, because Gateway API has no standard field for it. Both statements are sourced; they are about different layers, and the practical rule is to trust the warning in your own output over any table.

# Which ingress-nginx annotations does this cluster actually use, most common first?
# Compare the result with the provider README before you trust a clean run.
kubectl get ingress -A -o json \
  | jq -r '.items[].metadata.annotations // {} | keys[]' \
  | grep '^nginx.ingress.kubernetes.io/' \
  | sort | uniq -c | sort -rn

Inventory before you run. Count which ingress-nginx annotations your cluster actually uses, then compare the list with the provider README. Annotations that appear on many Ingresses and are not supported are your real migration cost.

The failure to plan for: a correct translation that returns 404

The Kubernetes post on surprising ingress-nginx behaviours states the risk pattern in one line: a seemingly correct translation can still cause an outage if it ignores ingress-nginx's quirks. It lists five. Regex paths are prefix matches and case-insensitive. use-regex applies to every path on that host across all Ingresses. rewrite-target silently implies regex. A request missing a trailing slash is redirected with a 301 to the slashed path. And URLs are normalised before matching, for instance collapsing double slashes and dot segments.

The sharpest example is the regex one. A pattern meant to match three uppercase letters matched /uuid in ingress-nginx, because of the prefix and case rules. Envoy-based implementations such as Istio, Envoy Gateway and Kgateway do a full case-sensitive match, so the faithful-looking route returns 404 for traffic that used to work. The post's fix is a case-insensitive prefix pattern, or the (?i) flag. The same logic bites rewrite-target: with a typo in a path that nobody noticed, because matching was forgiving, switching to Exact turns every forgiven request into a 404.

# Wrong: a "faithful" copy of use-regex with path /[A-Z]{3}.
# ingress-nginx matched /uuid (prefix + case-insensitive).
# A full, case-sensitive matcher returns 404 for it.
- matches:
  - path:
      type: RegularExpression
      value: "/[A-Z]{3}"

# Right: preserve the old semantics explicitly...
- matches:
  - path:
      type: RegularExpression
      value: "/[a-zA-Z]{3}.*"

# ...or use the (?i) flag, which Istio, Envoy Gateway and Kgateway support
- matches:
  - path:
      type: RegularExpression
      value: "(?i)/[a-z]{3}.*"

Do not clean up the tool's regex output before you know what clients send. Removing (?i) and the trailing .* is right only if no client calls the path in another case or with a suffix. A 404 at the gateway is silent in the sense that nothing in the migration fails until traffic arrives.

The trailing slash case is the mirror image. ingress-nginx redirected for you; a conformant Gateway API implementation does not configure any redirect silently, so anything relying on that 301 needs an explicit request redirect filter. Test all five with the same curl commands against both data planes before trusting the result.

Hand-edit the output into something you would defend

The release post's own edit pass is a good template. It removes the leading (?i) and trailing .* to get a case-sensitive exact pattern, lowers the request timeout from 10s to 3s on the grounds that the service should answer quickly, drops the port 80 listener and its redirect route because HTTP is not wanted, and leaves body size to the implementation's default after a check. Each edit answers one warning, which is the habit to copy. If a route should have no timeout at all, the Gateway API timeouts guide says to set request to 0s; HTTPRouteRequestTimeout is an extended support feature, so confirm your implementation supports it.

# The release post's hand-edited result (excerpt)
spec:
  gatewayClassName: nginx
  listeners:                          # port 80 listener removed: no HTTP wanted
  - hostname: my-host.example.com
    name: my-host-example-com-https
    port: 443
    protocol: HTTPS
    tls:
      certificateRefs:
      - group: ""
        kind: Secret
        name: my-secret
---
# HTTPRoute rule, after editing
    matches:
    - path:
        type: RegularExpression
        value: /users/(\d+)           # (?i) and trailing .* removed on purpose
    timeouts:
      request: 3s                     # was 10s as generated
# The ssl-redirect HTTPRoute (port 80, 308) was deleted with the listener.

Then verify before any traffic moves. Server-side dry-run catches schema errors, the route status tells you whether the controller accepted it, and curl with a Host header exercises the path rules without touching DNS. Check at least that the body size default suits you and that a three second timeout is enough, the same two checks the release post names.

# Schema and admission errors, without creating anything
kubectl apply --dry-run=server -f gwapi.yaml

# Apply to the DEV cluster, then read the status the controller wrote back
kubectl apply -f gwapi.yaml
kubectl get gateway,httproute -n my-ns
kubectl describe httproute my-ingress-my-host-example-com -n my-ns

# Exercise the rules without touching DNS: pin the host to the new gateway address
curl -isS --resolve my-host.example.com:443:<gateway-ip> https://my-host.example.com/users/42

Cut over without a big bang

The release post's cutover advice is short and worth following literally. Test in a development cluster, deploy the Gateway API configuration alongside the existing Ingress, shift traffic gradually with weighted DNS, a cloud load balancer or your platform's traffic splitting, and only when everything has moved, delete the Ingress resources and uninstall the Ingress controller. The reason is recoverability: a misconfiguration that survived your tests then costs you a weight change, not an incident.

#!/usr/bin/env bash
# My scaffolding, not part of ingress2gateway: same paths against both data planes,
# print any status code that differs. Fill in both addresses and your real paths.
HOST=my-host.example.com
OLD=<ingress-nginx-ip>
NEW=<gateway-ip>
for path in /users/42 /USERS/42 /users/42/orders /users/42/ //users/42; do
  a=$(curl -sk -o /dev/null -w '%{http_code}' --resolve "$HOST:443:$OLD" "https://$HOST$path")
  b=$(curl -sk -o /dev/null -w '%{http_code}' --resolve "$HOST:443:$NEW" "https://$HOST$path")
  [ "$a" = "$b" ] || echo "DIFF $path  ingress-nginx=$a  gateway=$b"
done

The trade-off is cost and time. Running two data planes in parallel means two sets of load balancers and two places a bug can hide, and the retirement of ingress-nginx, scheduled in the announcement for March 2026 with no further releases or security fixes after it, is the reason you cannot stay in parallel forever. Existing deployments keep working, according to the notice, so the pressure is security posture rather than an immediate outage.

What to do on Monday

Start with a read-only pass. Nothing below changes your cluster until step five.

  1. Count the ingress-nginx annotations your cluster uses and compare them with the provider README.
  2. Run ingress2gateway print against all namespaces, writing the manifest and the warnings to separate files.
  3. Turn every WARN into a ticket with an owner and a decision: translate, replace or drop.
  4. List the clients that send mixed-case or suffixed paths and the ones relying on trailing slash redirects.
  5. Apply to a development cluster, compare status codes against the old path, then shift traffic gradually.
# Steps 1 and 2, read-only. The manifest goes to stdout and the warnings to the
# terminal in the release post's examples, so split them to keep both.
ingress2gateway print --providers=ingress-nginx --all-namespaces \
  > gwapi.yaml 2> warnings.log

grep -c 'WARN' warnings.log
grep -B1 -A3 'Unsupported annotation' warnings.log

The comparison loop in the last block is my own scaffolding, not part of the tool: it fetches the same paths from both controllers and prints any status code that differs. Fill in the addresses and paths from your step four list.

Ingress2Gateway 1.0 is worth using because it is honest about its limits: it translates what it can prove, warns about the rest, and the project tests the translation against live controllers. The rule I would carry out of this: treat each warning as a decision, not noise, and treat a 404 from a faithful-looking route as the likeliest failure. Migrate with both planes running and delete Ingress last.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is Ingress2Gateway 1.0?

Ingress2Gateway 1.0 is the SIG Network migration assistant that translates Ingress resources and ingress-nginx annotations into Gateway API resources. It supports over 30 ingress-nginx annotations, up from three before 1.0. It warns about configuration it cannot translate instead of silently dropping it.

How do I convert ingress-nginx to Gateway API with Ingress2Gateway?

Install it with brew install ingress2gateway or go install, then run ingress2gateway print --providers=ingress-nginx with input files, a namespace or all namespaces. Redirect the output to a file and read the warnings. Then edit the manifest, test it in a development cluster and shift traffic gradually.

Which ingress-nginx annotations can Ingress2Gateway not translate?

The release post names configuration-snippet and, as a standard field, proxy-body-size. The provider README also lists recognised but unconverted annotations such as canary-by-cookie, canary-by-header-pattern, custom-headers and proxy-redirect-from. Always trust the warnings in your own run.

Why does my converted route return 404 when ingress-nginx worked?

ingress-nginx regex paths are prefix matches and case-insensitive, and rewrite-target silently implies regex. Envoy-based Gateway API implementations do full case-sensitive matches, so the same pattern can stop matching. Use a case-insensitive prefix pattern or the (?i) flag if clients vary case.

Can Ingress2Gateway migrate my cluster in one step?

No. The project describes it as an assistant, not a one-shot replacement. Run Gateway API alongside Ingress, shift traffic with weighted DNS or a load balancer, and delete Ingress and the controller only when all traffic has moved.