Migrating from Ingress NGINX to Cilium Gateway API on UpCloud Managed Kubernetes

Last updated on: October 28, 2026

Ingress NGINX retirement was announced on November 11, 2025. Best-effort maintenance has ended, and there are no further releases, bugfixes, or security updates. Existing deployments keep working and artifacts remain available.

The Kubernetes project recommends moving to the Gateway API or another ingress controller.

Gateway API is the Kubernetes project's successor to Ingress. It provides more flexible routing, clearer separation between platform and application responsibilities, and built-in support for capabilities like traffic splitting and header manipulation. On UpCloud Managed Kubernetes, Cilium's Gateway API support provides a compatible path forward.

UpCloud Managed Kubernetes uses Cilium as the default CNI. This guide shows how to enable the Gateway API in Cilium and verify that a Gateway can provision an UpCloud Load Balancer.

Prerequisites

  • A Managed Kubernetes cluster running Cilium 1.18 or later. See Check your Cilium version.
  • kubectl configured to access your cluster.
  • helm 3.14 or later installed locally.

Check if you are using Ingress NGINX

kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginx

If you see results, you are currently using ingress-nginx.

Check your Cilium version

Cilium's Gateway API support needs Cilium 1.18 or later. A cluster keeps the Cilium version it was created with, and upgrading the cluster to a newer Kubernetes version doesn't change it.

Check the version your cluster runs:

kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status | grep '^Cilium:'
Output
Cilium:                  Ok   1.18.6 (v1.18.6-95896696)

If your cluster runs an older version, create a new cluster and migrate your workloads with Velero. The supported versions page lists the Cilium version that new clusters come with.

Install Gateway API CRDs

Install the Gateway API v1.2.0 standard CRDs:

kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_gatewayclasses.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_gateways.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_httproutes.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_referencegrants.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_grpcroutes.yaml

Optional: TLSRoute is experimental and requires the experimental CRD:

kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/experimental/gateway.networking.k8s.io_tlsroutes.yaml

Enable Gateway API in Cilium

Back up your current values:

helm get values cilium -n kube-system > cilium_values_backup.yaml

Keep this file in case you need to roll back the Helm release.

Add the Cilium Helm repository:

helm repo add cilium https://helm.cilium.io

Note: If you receive an error that the repository already exists, you can safely skip the repo add command and proceed to repo update.

helm repo update

Find the chart version of your Cilium release in the CHART column:

helm list -n kube-system --filter '^cilium$'
Output
NAME    NAMESPACE       REVISION        UPDATED                                         STATUS          CHART           APP VERSION
cilium  kube-system     1               2026-10-28 06:52:34.700737907 +0000 UTC         deployed        cilium-1.18.6   1.18.6

Enable Gateway API on that same version. Replace 1.18.6 with the version from your CHART column:

helm upgrade cilium cilium/cilium \
  --version 1.18.6 \
  --namespace kube-system \
  --reset-then-reuse-values \
  --set gatewayAPI.enabled=true

Important: Use the chart version your cluster already runs, so that the upgrade only turns on Gateway API. The --reset-then-reuse-values flag keeps the settings UpCloud configured for your cluster. Don't use --reuse-values instead: if the chart version changes, Cilium keeps running the old version.

Note: kube-proxy replacement is already enabled by default on UpCloud Managed Kubernetes and does not need to be set explicitly.

Restart the Cilium operator and agents so that they pick up the change:

kubectl -n kube-system rollout restart deployment/cilium-operator
kubectl -n kube-system rollout restart ds/cilium

Verify that Gateway API is enabled:

kubectl -n kube-system get configmap cilium-config -o yaml | grep -E "^\s*enable-gateway-api:"

You should see enable-gateway-api: "true".

Deploy a test Gateway

When you enable Gateway API, Cilium automatically creates a GatewayClass named cilium. Verify it exists:

kubectl get gatewayclass

You should see:

Output
NAME     CONTROLLER                     ACCEPTED   AGE
cilium   io.cilium/gateway-controller   True       1m

Create test-gateway.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: my-test-gateway
  namespace: default
spec:
  gatewayClassName: cilium
  listeners:
  - name: http
    protocol: HTTP
    port: 80
    allowedRoutes:
      namespaces:
        from: All

Apply and check status:

kubectl apply -f test-gateway.yaml
kubectl get gateway my-test-gateway
kubectl -n default get svc

UpCloud now provisions a Managed Load Balancer for the Gateway. You can follow its progress in the UpCloud Control Panel under Load Balancer. When it's ready, the Gateway address and the Load Balancer Service show a hostname.

Example:

Output
NAME              CLASS    ADDRESS                       PROGRAMMED   AGE
my-test-gateway   cilium   lb-xxxx.upcloudlb.com         True         2m

Migration strategy

  1. Keep your existing Ingress running.
  2. Deploy a Gateway and HTTPRoute alongside it.
  3. Test the Gateway using a different hostname or internal testing.
  4. Update DNS to point to the Gateway Load Balancer.
  5. Monitor for issues.
  6. Delete old Ingress objects after confirming stability.

Migrating Ingress patterns to HTTPRoutes

Since every application is different, there is no single command to migrate everything. Use the reference examples below to translate your existing Ingress rules into Gateway API HTTPRoute objects.

Prerequisite: Make sure you have a Gateway running (as created in the "Deploy a test Gateway" section above).

Start by listing your Ingress objects:

kubectl get ingress -A

Choose the matching pattern

Pattern A: Simple path prefix

If your Ingress looks like this:

spec:
  rules:
  - host: app.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web
            port:
              number: 80

Create this HTTPRoute:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web
  namespace: default
spec:
  parentRefs:
  - name: my-test-gateway # Name of your Gateway
  hostnames:
  - app.example.com
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: web
      port: 80

Pattern B: Multiple paths

If your Ingress routes multiple paths (e.g. /api and /web):

spec:
  rules:
  - host: app.example.com
    http:
      paths:
      - path: /api
        ...
      - path: /web
        ...

Create this HTTPRoute:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: app
  namespace: default
spec:
  parentRefs:
  - name: my-test-gateway
  hostnames:
  - app.example.com
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /api
    backendRefs:
    - name: api-service
      port: 8080
  - matches:
    - path:
        type: PathPrefix
        value: /web
    backendRefs:
    - name: web-service
      port: 80

Pattern C: HTTPS / TLS

If you require HTTPS, you configure the TLS certificate on the Gateway, not the Route.

Create the secret:

kubectl create secret tls app-tls --cert=path/to.crt --key=path/to.key

Update your Gateway listener:

listeners:
- name: https
  protocol: HTTPS
  port: 443
  tls:
    mode: Terminate
    certificateRefs:
    - kind: Secret
      name: app-tls

By default, the load balancer in front of the Gateway answers HTTPS with its own certificate for the upcloudlb.com hostname. To serve your certificate, set the load balancer to pass HTTPS through, as shown in Customizing Load Balancer configuration with Gateway API.

Important: If you add an HTTPS listener that references a Secret that does not yet exist, the Gateway will report an Invalid CertificateRef error for that listener. This can prevent all traffic routing on the Gateway, including HTTP. Always ensure the TLS Secret exists before adding an HTTPS listener, or use cert-manager as described in Using cert-manager with Gateway API.

Note: If you use cert-manager, see Using cert-manager with Gateway API below.

Pattern D: Cross-Namespace access

A Gateway listener accepts Routes only from the Gateway's own namespace, unless its allowedRoutes setting allows more. A Route from a namespace that isn't allowed shows the reason NotAllowedByListeners in its status.

The test Gateway above allows Routes from all namespaces with from: All. To allow only selected namespaces, use a label selector:

listeners:
- name: http
  protocol: HTTP
  port: 80
  allowedRoutes:
    namespaces:
      from: Selector
      selector:
        matchLabels:
          team: shop

Then label the Route's namespace, for example apps, to match:

kubectl label namespace apps team=shop

If a Route sends traffic to a Service in another namespace, also create a ReferenceGrant in the Service's namespace. Without it, the Route's status shows the reason RefNotPermitted.

apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
  name: allow-apps-routes
  namespace: default # The namespace of the Service
spec:
  from:
  - group: gateway.networking.k8s.io
    kind: HTTPRoute
    namespace: apps # The namespace of the Route
  to:
  - group: ""
    kind: Service
    name: web # The name of the Service

Verify the Migration

Once you have applied an HTTPRoute, check that the Gateway has accepted it and that traffic is flowing correctly.

Check the status of your route:

kubectl describe httproute <route-name> -n <namespace>

Scroll down to the Status section at the bottom. Under Conditions, verify that the Type fields Accepted and ResolvedRefs both have their Status set to True

Next, test the connectivity using your Gateway's external address. You can find this address by listing the Gateway:

kubectl get gateway my-test-gateway

Then, use curl or your browser to access the application through that hostname:

curl http://<GATEWAY-ADDRESS>/<your-path>

If you receive the expected response from your application, the migration for that route is complete.

Routing TCP traffic

Note: TCPRoute and UDPRoute resources are not yet supported in Cilium's Gateway API implementation. This section describes the recommended standard approach for TCP load balancing.

The standard method to expose non-HTTP applications on UpCloud Managed Kubernetes is using a Kubernetes Service of type LoadBalancer. This creates a dedicated UpCloud Load Balancer for your TCP application.

1. Deploy your TCP Workload

Create a Deployment and Service. Note that type: LoadBalancer is used here instead of ClusterIP.

Important: You must include the upcloud-load-balancer-config annotation to explicitly set the mode to tcp. Without this, the Load Balancer defaults to HTTP mode and will reject raw TCP connections.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: tcp-echo
  namespace: default
spec:
  replicas: 2
  selector:
    matchLabels:
      app: tcp-echo
  template:
    metadata:
      labels:
        app: tcp-echo
    spec:
      containers:
      - name: tcp-echo
        image: istio/tcp-echo-server:1.2
        ports:
        - containerPort: 9000
        args: [ "9000", "hello" ]
---
apiVersion: v1
kind: Service
metadata:
  name: my-tcp-service
  namespace: default
  annotations:
    service.beta.kubernetes.io/upcloud-load-balancer-config: |
      {
        "frontends": [
          {
            "name": "tcp-app",
            "mode": "tcp",
            "port": 9000
          }
        ]
      }
spec:
  type: LoadBalancer
  selector:
    app: tcp-echo
  ports:
    - name: tcp-app
      protocol: TCP
      port: 9000
      targetPort: 9000

2. Verify Connectivity

Get the external address of the service:

kubectl get svc my-tcp-service

Wait until the EXTERNAL-IP column shows a hostname:

Output
NAME             TYPE           CLUSTER-IP     EXTERNAL-IP             PORT(S)          AGE
my-tcp-service   LoadBalancer   10.96.123.45   lb-xxx.upcloudlb.com    9000:30123/TCP   2m

Once the Load Balancer is provisioned, test connectivity using netcat (nc):

echo "UpCloud" | nc lb-xxx.upcloudlb.com 9000

Expected Output:

Output
hello UpCloud

UpCloud Managed Load Balancers don't support UDP traffic.

Note: Each Service of type LoadBalancer provisions its own UpCloud Managed Load Balancer, separate from any Gateway resources deployed.

Customizing Load Balancer configuration with Gateway API

By default, the UpCloud Cloud Controller Manager provisions Load Balancers with HTTP mode on port 443. When using Gateway API with TLS termination in Cilium, you need to configure the Load Balancer to use TCP mode instead. This allows TLS connections to pass through to Cilium's Envoy proxy.

You can customize the Load Balancer configuration directly in your Gateway spec using the infrastructure.annotations field:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: my-gateway
  namespace: default
spec:
  gatewayClassName: cilium
  infrastructure:
    annotations:
      service.beta.kubernetes.io/upcloud-load-balancer-config: |
        {
          "frontends": [
            {
              "name": "port-80",
              "mode": "http",
              "port": 80
            },
            {
              "name": "port-443",
              "mode": "tcp",
              "port": 443
            }
          ]
        }
  listeners:
  - name: http
    protocol: HTTP
    port: 80
    allowedRoutes:
      namespaces:
        from: All
  - name: https
    protocol: HTTPS
    port: 443
    tls:
      mode: Terminate
      certificateRefs:
      - kind: Secret
        name: app-tls
    allowedRoutes:
      namespaces:
        from: All

Important notes:

  • List a frontend for every port the load balancer should serve. A port that's missing from the annotation gets no frontend, so leaving out port-80 stops plain HTTP and cert-manager's HTTP-01 challenges.
  • Don't set a hostname on the HTTPS listener. With a hostname set, plain HTTP requests for that hostname return 404.
  • The frontend name must match the Service port name that Cilium creates (typically port-<number>)
  • For TLS termination with cert-manager, TCP mode is required to prevent certificate mismatches
  • The annotation supports all UpCloud Load Balancer configuration options - see the Load Balancer API documentation for details

This approach is recommended over post-creation annotations because:

  • Everything is declarative in one file
  • Changes can be tracked in version control
  • No manual kubectl commands needed

Using cert-manager with Gateway API (optional)

If you currently use cert-manager to provision TLS certificates for your Ingress resources, you can continue using it with Gateway API. However, there are several important configuration steps required.

Step 1: Install cert-manager with Gateway API support

cert-manager's Gateway API support is disabled by default. Enable it during installation:

helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true \
  --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
  --set config.kind="ControllerConfiguration" \
  --set config.enableGatewayAPI=true

Wait for cert-manager to be ready:

kubectl -n cert-manager rollout status deployment/cert-manager
kubectl -n cert-manager rollout status deployment/cert-manager-webhook

Verify Gateway API support is enabled by checking the logs:

kubectl -n cert-manager logs deployment/cert-manager --tail=20 | grep -i gateway

You should not see "skipping disabled controller" for gateway-shim.

Note: If cert-manager is already installed without Gateway API support, enable it on the version you already run. Replace v1.17.4 with the version in the CHART column of helm list -n cert-manager:

helm upgrade cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --version v1.17.4 \
  --reset-then-reuse-values \
  --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
  --set config.kind="ControllerConfiguration" \
  --set config.enableGatewayAPI=true

Step 2: Create a ClusterIssuer

Create a ClusterIssuer that uses HTTP-01 challenges via your Gateway:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
    - http01:
        gatewayHTTPRoute:
          parentRefs:
          - name: my-test-gateway
            namespace: default
            kind: Gateway

Apply and verify:

kubectl apply -f clusterissuer.yaml
kubectl get clusterissuer letsencrypt-prod

Step 3: Issue the certificate before adding HTTPS

Important: There is a chicken-and-egg problem with Gateway API and cert-manager. If you add an HTTPS listener that references a Secret before cert-manager creates it, the Gateway reports Invalid CertificateRef and may stop routing all traffic, including HTTP. This prevents cert-manager's HTTP-01 challenge from completing.

To avoid this, issue the certificate while the Gateway is HTTP-only:

  1. Create a Certificate resource that references your HTTP-only Gateway:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: app-tls
  namespace: default
spec:
  secretName: app-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
  - app.example.com
  1. Wait for the certificate to be issued:
kubectl get certificate app-tls -w

Once READY shows True, the Secret exists and you can proceed.

Step 4: Add the HTTPS listener

Now add the HTTPS listener to your Gateway:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: my-test-gateway
  namespace: default
spec:
  gatewayClassName: cilium
  infrastructure:
    annotations:
      service.beta.kubernetes.io/upcloud-load-balancer-config: |
        {
          "frontends": [
            {
              "name": "port-80",
              "mode": "http",
              "port": 80
            },
            {
              "name": "port-443",
              "mode": "tcp",
              "port": 443
            }
          ]
        }
  listeners:
  - name: http
    protocol: HTTP
    port: 80
    allowedRoutes:
      namespaces:
        from: All
  - name: https
    protocol: HTTPS
    port: 443
    tls:
      mode: Terminate
      certificateRefs:
      - kind: Secret
        name: app-tls
    allowedRoutes:
      namespaces:
        from: All

Step 5: Configure the Load Balancer for TLS passthrough

By default, the UpCloud Load Balancer will auto-provision its own TLS certificate for port 443, which won't match your domain and will cause certificate errors.

To fix this, include the Load Balancer TCP mode configuration in your Gateway spec as shown in the Customizing Load Balancer configuration section above.

The complete Gateway spec with HTTPS listener and proper Load Balancer configuration is shown in Step 4.

Certificate renewal

cert-manager will automatically renew certificates before they expire. However, because the HTTP-01 challenge requires HTTP traffic to work, ensure:

  1. Your Gateway's HTTP listener remains active
  2. The Load Balancer configuration includes the port-80 frontend

If you want to redirect HTTP to HTTPS, do so at the application level rather than removing the HTTP listener entirely.

Cleanup

To remove the test Gateway and its associated Load Balancer:

kubectl delete gateway my-test-gateway

This deletes the Gateway, its underlying Service, and triggers deletion of the associated UpCloud Managed Load Balancer.

Important: If you delete your Kubernetes cluster without first deleting Gateway and Service objects, the Managed Load Balancers will not be automatically removed. You will need to delete them manually via the UpCloud Control Panel.

Optional: Remove Ingress NGINX after migration

After you have validated traffic through Gateway API, remove the old Ingress objects and Ingress NGINX.

List Ingress objects and delete only the ones you have migrated:

kubectl get ingress -A
kubectl delete ingress <name> -n <namespace>

Remove ingress-nginx:

helm uninstall ingress-nginx -n ingress-nginx

Optional: remove the namespace:

kubectl delete namespace ingress-nginx

Troubleshooting

No external address after several minutes:

First, find the Service created for your Gateway:

kubectl get svc -n <namespace> -l 'gateway.networking.k8s.io/gateway-name=<gateway-name>'

Then describe it to see events and load balancer provisioning status:

kubectl describe svc -n <namespace> -l 'gateway.networking.k8s.io/gateway-name=<gateway-name>'

Replace <namespace> with your Gateway's namespace (for example, default) and <gateway-name> with your Gateway's name (for example, my-test-gateway).

Check the UpCloud Control Panel under Load Balancer to verify provisioning status.

Gateway stuck in Pending or NotProgrammed:

kubectl describe gateway <gateway-name> -n <namespace>

Look at the Gateway conditions for Accepted and Programmed, and the reason message.

HTTPRoute not attaching:

kubectl get httproute <route-name> -n <namespace> -o yaml | grep -A 10 conditions

Look for Accepted and ResolvedRefs conditions and their reasons.

TLSRoute not working:

Install the experimental TLSRoute CRD.

Gateway not programmed:

Confirm gatewayAPI.enabled=true in cilium-config.

kubectl -n kube-system get configmap cilium-config -o yaml | grep -E "enable-gateway-api"

The value should be "true".

HTTPS returns certificate error / wrong certificate:

If you see a certificate for lb-xxxx.upcloudlb.com instead of your domain, the Load Balancer is terminating TLS instead of passing it through.

For new Gateways, use the infrastructure.annotations approach shown in Customizing Load Balancer configuration.

For existing Gateways, you can fix this by annotating the Service:

kubectl annotate svc cilium-gateway-<gateway-name> \
  'service.beta.kubernetes.io/upcloud-load-balancer-config={"frontends":[{"name":"port-80","mode":"http","port":80},{"name":"port-443","mode":"tcp","port":443}]}'

Gateway shows "Invalid CertificateRef" and HTTP stops working:

The HTTPS listener is referencing a Secret that doesn't exist. Either:

  • Create the Secret manually, or
  • Remove the HTTPS listener temporarily, issue the certificate using cert-manager, then re-add the HTTPS listener

cert-manager not creating certificates for Gateway:

Check if Gateway API support is enabled:

kubectl -n cert-manager logs deployment/cert-manager --tail=50 | grep -i gateway

If you see "skipping disabled controller" for gateway-shim, enable it on the version you already run. Replace v1.17.4 with the version in the CHART column of helm list -n cert-manager:

helm upgrade cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --version v1.17.4 \
  --reset-then-reuse-values \
  --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
  --set config.kind="ControllerConfiguration" \
  --set config.enableGatewayAPI=true
Contributed by: Samir Haliru

Can't find what you're looking for?

For more help you can contact our awesome 24/7 support team