Post

Migrating Ingress NGINX to Gateway API - Envoy Gateway with Cilium as the L2 Load Balancer

Migrating Ingress NGINX to Gateway API - Envoy Gateway with Cilium as the L2 Load Balancer

Like all things in technology (and in life), retirement is inevitable. While building my Talos Kubernetes cluster a few years back, choosing Ingress NGINX was a no-brainer. For someone like me who started their career as a Middleware administrator, the Ingress annotations never felt scary. In fact, I liked the granular control they offered for customizing my Ingress resources. Definitely a questionable security choice – but for a home lab it worked really well.

Learning that the project was being retired in March 2026, in favour of the Gateway API, was genuinely disheartening. It will never be a seamless migration since the Gateway API takes an entirely different approach to routing requests to k8s workloads. But let’s look at the bright side – it’s an opportunity to learn and tinker with a new technology, which is why I personally run a home lab in the first place!

The Gateway API is the modern successor to Ingress, using a modular design that splits responsibilities between infrastructure owners (who manage the Gateways) and developers (who manage the Routes). Multiple self-managed and cloud-provider-managed implementations are available for the Gateway API – Istio, Envoy Gateway, Cilium, NGINX Gateway Fabric, Azure Application Gateway for Containers, and others. In this guide I’ll be using Envoy Gateway, but before we get into it, let’s look at a few of the self-managed implementations to help pick the right one.

Choosing a Gateway API Implementation

With around 20 Ingress resources of varying complexity, I needed a Gateway API implementation that could handle all of it while keeping things simple. My requirements:

  • HTTPS backends with re-encryption
  • Path blocking on specific apps
  • Forward authentication
  • TCP streams for exposing databases outside the cluster

Cilium was the obvious first stop since it was already running on the cluster as the networking plugin. However, it was missing a few things I needed – TCPRoute support and forward authentication.

NGINX Gateway Fabric was tempting due to my familiarity with NGINX. However, it also lacked forward authentication support.

Istio was an interesting option, but its Gateway API implementation trails behind its own native VirtualService and Gateway model – several advanced features aren’t exposed through the Gateway API surface yet.

Envoy Gateway checked all the boxes – TCPRoute and BackendTLSPolicy support, extensible auth, and advanced customization options via the EnvoyProxy CRD. The project has solid upstream momentum and an active community.

Here’s a rough comparison of the features I care about:

Requirement Gateway API Feature Cilium Gateway NGINX Gateway Fabric Istio Envoy Gateway
HTTPS backends BackendTLSPolicy Yes Yes Yes Yes
Path blocking HTTPRoute rules Yes Yes Yes Yes
Forward auth / OIDC SecurityPolicy No No Partial Yes
TCP streams TCPRoute No Yes Yes Yes
Custom extensions EnvoyProxy / CRDs Limited Limited Limited (via Gateway API) Yes

This table reflects my assessment at the time of migration. Both Cilium Gateway and NGINX Gateway Fabric are actively developed – check the current docs before making your own call.

High-Level Design

Current Architecture

HLD of Current Architecture

Proposed Architecture

HLD of Proposed Architecture

Yes, this definitely looks complex – it should make more sense as we start implementing.

Prerequisites

  • A Kubernetes cluster (can be single-node or multi-node, bare metal or cloud). I used my Talos k8s v1.33.1 cluster for this guide. Check out this post to see how I built it.
  • Cilium as your networking plugin (CNI).
  • A Linux VM to use as a workstation or bastion host. In this guide, I’m using Debian 13 (trixie).
  • Basic knowledge of Linux, containers, and Kubernetes.

Configuring Cilium for L2 Load Balancing

If you already have Cilium installed, you can enable L2 announcements by updating your Helm values. Upgrade your existing Cilium installation with:

1
2
3
4
5
6
7
8
9
helm upgrade cilium oci://quay.io/cilium/charts/cilium \
  --version 1.19.0 \
  --namespace kube-system \
  --set ipam.mode=kubernetes \
  --set kubeProxyReplacement=true \
  --set l2announcements.enabled=true \
  --set externalIPs.enabled=true \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true

kubeProxyReplacement is required for L2 announcements to function – Cilium needs to own the kube-proxy responsibilities to handle ARP responses correctly.

This can also be done via the Cilium CLI using cilium upgrade. See the official upgrade docs for details.

Creating the L2 Announcement Policy and IP Pool

If you don’t already have an L2 announcement policy and IP pool configured, create them now. These define which interface Cilium advertises on and which IP range it allocates from.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cat <<EOF | kubectl apply -f -
apiVersion: "cilium.io/v2alpha1"
kind: CiliumL2AnnouncementPolicy
metadata:
  name: my-l2-policy
spec:
  interfaces:
  - eth0
  externalIPs: true
  loadBalancerIPs: true
---
apiVersion: "cilium.io/v2"
kind: CiliumLoadBalancerIPPool
metadata:
  name: "my-lb-pool"
spec:
  blocks:
  - cidr: "192.168.0.195/30"
EOF

The /30 CIDR gives exactly two usable IPs which I use as my internal and external Gateways. Update the cidr block to match your own network range and however many IPs your setup requires.

Make sure the IP range you choose doesn’t overlap with any existing DHCP pool or statically assigned addresses on your network.

Installing Envoy Gateway

1
2
3
4
helm install eg oci://docker.io/envoyproxy/gateway-helm \
  --version v1.7.0 \
  --namespace envoy-gateway-system \
  --create-namespace

Once the installation is complete, verify the controller pod is running:

1
kubectl get pods -n envoy-gateway-system

Creating the GatewayClass

The GatewayClass is a cluster-wide resource that tells Envoy Gateway to manage any Gateway resource that references it. Create it with:

1
2
3
4
5
6
7
8
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy-gateway
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
EOF

Confirm it’s been accepted by the controller:

1
2
3
kubectl get gatewayclass envoy-gateway
# NAME            CONTROLLER                                      ACCEPTED   AGE
# envoy-gateway   gateway.envoyproxy.io/gatewayclass-controller   True       31d

You should see ACCEPTED: True in the output before proceeding.

Creating Your First Gateway

Before creating any Gateway resources, let’s spin up a simple demo workload to test the routing.

Demo Application

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
  name: demo
---
apiVersion: v1
kind: Pod
metadata:
  name: hello-world
  namespace: demo
  labels:
    app: hello-world
spec:
  containers:
    - name: hello-world
      image: hashicorp/http-echo:0.2.3
      args:
        - "-text=Hello from Envoy!"
      ports:
        - containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
  name: hello-world-svc
  namespace: demo
spec:
  selector:
    app: hello-world
  ports:
    - port: 80
      targetPort: 5678
  type: ClusterIP
EOF

Fixing externalTrafficPolicy for Cilium L2

Before creating the Gateway, there’s an important gotcha to address. Because Cilium L2 advertisements work at the ARP level, traffic arriving at the advertised IP will only reach the node currently announcing it. If your Envoy pods are spread across multiple nodes, you’ll get intermittent connectivity – requests silently failing depending on which node the packet lands on.

The fix is to set externalTrafficPolicy: Cluster on the LoadBalancer service that Envoy Gateway provisions (Thanks to this thread on r/kubernetes for the fix!). This ensures Cilium forwards the traffic correctly regardless of which node receives it. Fortunately, Envoy Gateway lets us patch the underlying service via the EnvoyProxy CRD rather than touching it directly.

Create the gateways namespace and the EnvoyProxy config:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
  name: gateways
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
  name: my-proxy-config
  namespace: gateways
spec:
  provider:
    type: Kubernetes
    kubernetes:
      envoyService:
        patch:
          type: StrategicMerge
          value:
            spec:
              externalTrafficPolicy: Cluster
EOF

Creating the Gateway

With the proxy config in place, create the Gateway. I’m using a wildcard hostname on the HTTPS listener since a single Gateway handles multiple apps, and a single wildcard cert covers them all. I am also requesting a specific IP from the Cilium pool.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: my-gateway
  namespace: gateways
spec:
  infrastructure:
    annotations:
      "lbipam.cilium.io/ips": "192.168.0.193"  # Request a specific IP from the Cilium pool
    parametersRef:
      group: gateway.envoyproxy.io
      kind: EnvoyProxy
      name: my-proxy-config
  gatewayClassName: envoy-gateway
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      allowedRoutes:
        namespaces:
          from: All
    - name: https
      port: 443
      protocol: HTTPS
      hostname: "*.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: my-tls-secret
      allowedRoutes:
        namespaces:
          from: All
EOF

Verify the Gateway comes up and gets its IP assigned:

1
2
3
kubectl get gateway -n gateways
# NAME                  CLASS           ADDRESS         PROGRAMMED   AGE
# my-gateway            envoy-gateway   192.168.0.193   True         31d

You should see the assigned address under ADDRESS and PROGRAMMED: True before moving on. In my case, I just created two gateways for separating internal and external traffic.

To ensure that the externalTrafficPolicy we set in the previous step is correct – try:

1
2
3
4
5
6
7
8
# Find your LoadBalancer service
kubectl get svc -n envoy-gateway-system | grep LoadBalancer
# envoy-gateways-my-gateway-9fbebd10   LoadBalancer   10.15.127.25   192.168.0.193   80:31033/TCP,443:30131/TCP,3306:32621/TCP          23d

# Verify externalTrafficPolicy is set to cluster
kubectl describe svc envoy-gateways-my-gateway-9fbebd10 -n envoy-gateway-system | grep "Traffic Policy"
# External Traffic Policy:  Cluster
# Internal Traffic Policy:  Cluster

HTTP to HTTPS Redirect

Optionally create a catch-all redirect route on the HTTP listener to redirect all HTTP traffic to the HTTPS listener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: http-to-https-redirect
  namespace: gateways
spec:
  parentRefs:
    - name: my-gateway
      namespace: gateways
      sectionName: http
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301
EOF

Routing Traffic to the Demo App

With the Gateway up, create an HTTPRoute in the demo namespace pointing at the demo service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: hello-world-route
  namespace: demo
spec:
  parentRefs:
    - name: my-gateway
      namespace: gateways
      sectionName: https
  hostnames:
    - hellodemo.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: hello-world-svc
          port: 80
EOF

Testing

1
curl -k -v --resolve hellodemo.example.com:443:192.168.0.193 https://hellodemo.example.com # Where 192.168.0.193 is the IP assigned to the gateway

You should get back:

1
Hello from Envoy!

Migrating Existing Ingress Resources

Third-Party Helm Charts

If your apps are deployed via third-party Helm charts, check the chart’s values first – many popular charts already support HTTPRoute natively. It’s often just a simple switch in your values.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Before
ingress:
  enabled: true
  className: nginx

# After
ingress:
  enabled: false
httproute:
  enabled: true
  hostnames:
    - app.example.com
  parentRefs:
    - name: my-gateway
      namespace: gateways
      sectionName: https

Check the chart’s documentation for the exact field names – some charts have added dedicated HTTPRoute sections alongside the legacy ingress block.

ingress2gateway

For custom Ingress resources, the ingress2gateway tool from kubernetes-sigs can generate HTTPRoute manifests directly from your existing Ingress resources. It won’t cover every annotation (more on that below), but it handles the boilerplate to get you started.

Download the latest binary from the releases page and run it against your cluster:

1
2
3
4
5
6
# Download and extract (replace VERSION with the latest release)
curl -LO https://github.com/kubernetes-sigs/ingress2gateway/releases/download/<VERSION>/ingress2gateway_Linux_x86_64.tar.gz
tar -xzf ingress2gateway_Linux_x86_64.tar.gz

# Convert all Ingress resources in a namespace
./ingress2gateway print --providers ingress-nginx --namespace demo

The output is a set of Gateways and HTTPRoute manifests you can review, modify, and apply.

HTTPS Backends

For apps that serve traffic over HTTPS internally (re-encryption rather than termination at the gateway), the equivalent of the old nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" annotation is a BackendTLSPolicy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1alpha3
kind: BackendTLSPolicy
metadata:
  name: my-app-backend-tls
  namespace: demo
spec:
  targetRefs:
    - group: ""
      kind: Service
      name: my-app-svc
  validation:
    caCertificateRefs:
      - group: ""
        kind: ConfigMap
        name: my-app-ca-cert
    hostname: my-app.internal
EOF

The BackendTLSPolicy attaches to the Service directly. Your HTTPRoute stays the same – the policy handles the re-encryption transparently.

The CA cert needs to be present as a ConfigMap in the same namespace as the Service. If you’re using cert-manager with an internal CA, you can export the CA cert and create the ConfigMap from it.

Path Blocking

The old nginx.ingress.kubernetes.io/server-snippet annotation for blocking specific paths is replaced by match rules directly in the HTTPRoute. There are two approaches depending on what you want to do – redirect the request to a different path, or return a direct error response via HTTPRouteFilter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: my-app-route
  namespace: demo
spec:
  parentRefs:
    - name: my-gateway
      namespace: gateways
      sectionName: https
  hostnames:
    - myapp.example.com
  rules:
    # Redirect /admin back to / with a 301
    - matches:
        - path:
            type: PathPrefix
            value: /admin
      filters:
        - type: RequestRedirect
          requestRedirect:
            statusCode: 301
            path:
              type: ReplaceFullPath
              replaceFullPath: /
    # Return a 403 directly for /superadmin via HTTPRouteFilter
    - matches:
        - path:
            type: PathPrefix
            value: /superadmin
      filters:
        - type: ExtensionRef
          extensionRef:
            group: gateway.envoyproxy.io
            kind: HTTPRouteFilter
            name: forbidden-filter
    # Catch-all rule for legitimate traffic
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: my-app-svc
          port: 80
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: HTTPRouteFilter
metadata:
  name: forbidden-filter
  namespace: demo
spec:
  directResponse:
    contentType: text/plain
    statusCode: 403
    body:
      type: Inline
      inline: "Oops! Your request is forbidden."
EOF

The HTTPRouteFilter with directResponse is an Envoy Gateway extension – it’s not part of the core Gateway API spec, which is why it’s referenced via ExtensionRef rather than inline in the rule. Rule order matters here; the blocking rules are evaluated top-down, so always place them above the catch-all.

TCP Streams

The equivalent of Ingress NGINX’s TCP stream ConfigMap is a TCPRoute. The Gateway needs a dedicated TCP listener on the port you want to expose. Update your gateway with an additional listener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
  listeners:
    - name: mariadb
      protocol: TCP
      port: 3306
      allowedRoutes:
        kinds:
          - kind: TCPRoute
        namespaces:
          # Allow only from databases namespace
          from: Selector
          selector:
            matchLabels:
              kubernetes.io/metadata.name: databases
...

Then create the TCPRoute to forward traffic to your database service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TCPRoute
metadata:
  name: mariadb
  namespace: databases
spec:
  parentRefs:
    - name: my-gateway
      namespace: gateways
      sectionName: mariadb
  rules:
    - backendRefs:
        # Backend SVC name and port
        - name: mariadb-cluster
          port: 3306
EOF

Troubleshooting

Gateway API introduces more components than a single Ingress controller, making troubleshooting more complex when issues arise. Here are some useful commands for debugging:

Useful Commands

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Check Gateway status and assigned IP
kubectl describe gateway my-gateway -n gateways

# Check HTTPRoute status and whether it has been accepted by the Gateway
kubectl describe httproute hello-world-route -n demo

# Check TCPRoute status
kubectl describe tcproute mariadb -n demo

# Check Envoy Gateway controller logs
kubectl logs -n envoy-gateway-system deployment/envoy-gateway -f

# Check the Envoy proxy pod logs (data plane)
kubectl logs -n envoy-gateway-system -l gateway.envoyproxy.io/owning-gateway-name=my-gateway -f

Admin Console

Envoy Gateway ships with an admin console that you can use to view the stats, metrics, and config dumps. It is useful for verifying that gateways and routes are being picked up correctly by the Envoy data plane.

1
2
# Access the Envoy admin console
kubectl port-forward -n envoy-gateway-system deployment/envoy-gateway --address 0.0.0.0 19000:19000

Once the port-forward is running, open http://localhost:19000 in your browser.

Envoy Admin Console

Restarting Things

And of course, the restart of shame/fame 😄 – especially after making changes to Cilium config or if the Gateway isn’t getting an IP assigned.

1
2
3
4
5
6
7
8
9
# Restart Cilium
kubectl -n kube-system rollout restart deployment/cilium-operator
kubectl -n kube-system rollout restart ds/cilium

# Restart Envoy Gateway controller
kubectl rollout restart -n envoy-gateway-system deployment/envoy-gateway

# Restart the Envoy proxy deployment for a specific Gateway
kubectl rollout restart -n envoy-gateway-system deployment/envoy-gateways-my-gateway-ed6cc5f5

Additionally, please have a look at the official Envoy Gateway troubleshooting docs for more information on advanced troubleshooting.

Conclusion

Migrating from Ingress NGINX to Envoy Gateway took more than a weekend; I replaced a single DaemonSet and some annotations with a GatewayClass, two Gateways, an EnvoyProxy, a handful of HTTPRoutes, TCPRoutes, BackendTLSPolicies, SecurityPolicies, and HTTPRouteFilters. There is definitely a resource overhead, but it shouldn’t affect me since I don’t monitor it 😄 – or, maybe I should implement full-stack observability and make it even worse (idea for next blog post!).

In all seriousness, the learning was genuinely worth it – and when Ingress NGINX is fully retired and everyone else is scrambling, I’ll be sitting here with my beautifully over-engineered Gateway API setup, feeling very smug about it.

Hope you had fun migrating. Peace. ✌️

Credits and References

This post is licensed under CC BY 4.0 by the author.