Migrating from Ingress NGINX to Cilium Gateway API on UpCloud Managed Kubernetes
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.
kubectlconfigured to access your cluster.helm3.14 or later installed locally.
Check if you are using Ingress NGINX
kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginxIf 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:'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.yamlOptional: 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.yamlEnable Gateway API in Cilium
Back up your current values:
helm get values cilium -n kube-system > cilium_values_backup.yamlKeep this file in case you need to roll back the Helm release.
Add the Cilium Helm repository:
helm repo add cilium https://helm.cilium.ioNote: 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 updateFind the chart version of your Cilium release in the CHART column:
helm list -n kube-system --filter '^cilium$'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.6Enable 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=trueImportant: 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/ciliumVerify 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 gatewayclassYou should see:
NAME CONTROLLER ACCEPTED AGE
cilium io.cilium/gateway-controller True 1mCreate 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: AllApply and check status:
kubectl apply -f test-gateway.yaml
kubectl get gateway my-test-gateway
kubectl -n default get svcUpCloud 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:
NAME CLASS ADDRESS PROGRAMMED AGE
my-test-gateway cilium lb-xxxx.upcloudlb.com True 2mMigration strategy
- Keep your existing Ingress running.
- Deploy a Gateway and HTTPRoute alongside it.
- Test the Gateway using a different hostname or internal testing.
- Update DNS to point to the Gateway Load Balancer.
- Monitor for issues.
- 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 -AChoose 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: 80Create 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: 80Pattern 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: 80Pattern 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.keyUpdate your Gateway listener:
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: app-tlsBy 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: shopThen label the Route's namespace, for example apps, to match:
kubectl label namespace apps team=shopIf 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 ServiceVerify 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-gatewayThen, 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: 90002. Verify Connectivity
Get the external address of the service:
kubectl get svc my-tcp-serviceWait until the EXTERNAL-IP column shows a hostname:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
my-tcp-service LoadBalancer 10.96.123.45 lb-xxx.upcloudlb.com 9000:30123/TCP 2mOnce the Load Balancer is provisioned, test connectivity using netcat (nc):
echo "UpCloud" | nc lb-xxx.upcloudlb.com 9000Expected Output:
hello UpCloudUpCloud 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: AllImportant 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-80stops plain HTTP and cert-manager's HTTP-01 challenges. - Don't set a
hostnameon the HTTPS listener. With a hostname set, plain HTTP requests for that hostname return 404. - The frontend
namemust match the Service port name that Cilium creates (typicallyport-<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=trueWait for cert-manager to be ready:
kubectl -n cert-manager rollout status deployment/cert-manager
kubectl -n cert-manager rollout status deployment/cert-manager-webhookVerify Gateway API support is enabled by checking the logs:
kubectl -n cert-manager logs deployment/cert-manager --tail=20 | grep -i gatewayYou 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=trueStep 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: GatewayApply and verify:
kubectl apply -f clusterissuer.yaml
kubectl get clusterissuer letsencrypt-prodStep 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:
- 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- Wait for the certificate to be issued:
kubectl get certificate app-tls -wOnce 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: AllStep 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:
- Your Gateway's HTTP listener remains active
- The Load Balancer configuration includes the
port-80frontend
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-gatewayThis 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-nginxOptional: remove the namespace:
kubectl delete namespace ingress-nginxTroubleshooting
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 conditionsLook 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 gatewayIf 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