Migrating from Ingress NGINX to Cilium Gateway API on UpCloud Managed Kubernetes
Ingress NGINX retirement was announced on November 11, 2025. Best-effort maintenance will continue until March 2026. After that, there will be no releases, bugfixes, or security updates. Existing deployments will 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 version 1.32 or later.
- On 1.30 or 1.31? You can upgrade in place - one minor version at a time from the UpCloud Control Panel or API.
- On 1.29 or older? Create a new 1.32+ cluster and migrate your workloads with Velero.
kubectlconfigured to access your cluster.helmv3 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.
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
On UpCloud Managed Kubernetes 1.32+, the default Cilium version already meets the v1.18+ requirement. You only need to enable Gateway API.
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.
Enable Gateway API:
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 update
helm upgrade cilium cilium/cilium \
--version 1.18.7 \
--namespace kube-system \
--reuse-values \
--set gatewayAPI.enabled=trueImportant: The --version flag pins the Helm chart to the 1.18.x line that ships with UpCloud Managed Kubernetes. Without it, Helm pulls the latest chart (currently 1.19), which requires additional upgrade steps and is not compatible with --reuse-values.
Note: kube-proxy replacement is already enabled by default on UpCloud Managed Kubernetes and does not need to be set explicitly.
Restart Cilium components:
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 will now provision a Managed Load Balancer, which takes about 2 minutes. You can monitor the progress in the UpCloud Control Panel under Load Balancers. When it finishes, the Gateway address and the Load Balancer Service should 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-tlsImportant: 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
By default, a Gateway may not trust Routes from other namespaces. If you encounter permissions errors when attaching a Route in one namespace (e.g. apps) to a Gateway in another (e.g. gateway-system), you use a ReferenceGrant.
Create this in the Gateway's namespace:
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-apps-routes
namespace: default # The namespace of the Gateway
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: apps # The namespace of the Route
to:
- group: gateway.networking.k8s.io
kind: Gateway
name: my-test-gateway # The name of the GatewayVerify 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 and UDP 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/UDP 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/UDP 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 for the EXTERNAL-IP column to show a hostname (this takes about 2 minutes):
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 UpCloudFor UDP services, the process is similar. Simply change the protocol in the Service spec and update the load balancer config mode.
annotations:
service.beta.kubernetes.io/upcloud-load-balancer-config: |
{
"frontends": [
{
"name": "udp-app",
"mode": "udp",
"port": 9000
}
]
}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-443",
"mode": "tcp",
"port": 443
}
]
}
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
hostname: app.example.com
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: app-tls
allowedRoutes:
namespaces:
from: AllImportant notes:
- 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, you can enable it with:
helm upgrade cert-manager jetstack/cert-manager \
--namespace cert-manager \
--reuse-values \
--set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
--set config.kind="ControllerConfiguration" \
--set config.enableGatewayAPI=true
kubectl -n cert-manager rollout restart deployment/cert-managerStep 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-443",
"mode": "tcp",
"port": 443
}
]
}
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
hostname: app.example.com
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 allows HTTP traffic on port 80
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 Hub under Load Balancers 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-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:
helm upgrade cert-manager jetstack/cert-manager \
--namespace cert-manager \
--reuse-values \
--set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
--set config.kind="ControllerConfiguration" \
--set config.enableGatewayAPI=true
kubectl -n cert-manager rollout restart deployment/cert-managerCilium version not updating after upgrade:
Only relevant if you have previously customised Cilium image tags or pinned versions.
If the upgrade command finishes but your Cilium pods remain on an older version, your existing Helm values may have pinned the image tags. The --reuse-values flag respects these pins, preventing the upgrade.
To fix this:
- Check your values:
helm get values cilium -n kube-system -a > cilium_values_all.yaml - Look for
image.tagoroperator.image.tag. - If found, re-run the upgrade command, adding
--set image.tag=v1.18.7 --set operator.image.tag=v1.18.7(or whichever version matches your chart) to override the pin.
