Ingress: Getting HTTP In From Outside (With Cilium)

K
Kai··7 min read

Article 47 controlled pod-to-pod traffic inside the cluster. But real users sit outside the cluster and type an HTTP URL. A ClusterIP Service only lives inside the cluster; NodePort exposes a hard-to-remember port number, one port per Service. What we want is a single HTTP door that takes every request, then looks at the host and path to route to the right Service. That's the job of Ingress.

Which controller in 2026

There's something that directly shapes the controller choice. In November 2025, SIG Network announced the retirement of Ingress NGINX — the most popular controller, running on roughly half the clusters in the world. From March 2026, the project stopped maintenance: no releases, no bug fixes, no security patches. This article is written in May 2026, so Ingress NGINX is already past end of life, and installing it for a new system now is the wrong call from the start.

On top of that, the kubernetes.io docs note that the Ingress API is frozen: still GA, still supported, but no new features, and Gateway API (Article 49) is the successor. Frozen doesn't mean gone — Ingress is still all over production clusters in 2026, so you still need to know how it runs. For this cluster, the cleanest approach is to use the Ingress controller built into Cilium, the CNI we've been running since Article 46: no third controller to install, no end-of-life software, and Cilium translates Ingress down to Envoy + eBPF so it continues straight from Article 45.

Ingress needs a running controller

The docs stress a point people often miss: creating an Ingress object does nothing on its own. You need a running Ingress controller to read Ingress objects and configure the actual proxy. Kubernetes enables no controller by default; you have to choose one. The path:

   client (outside)
       │  HTTP, Host: foo.kkloud.local
       ▼
   NodePort 32080 on worker  ──(eBPF, Article 46)──►  Envoy (cilium-envoy)
                                                        │  reads routing rules
                                                        ▼
   ┌─────────────── Ingress controller (in cilium-operator) ───────────────┐
   │  watch Ingress object  →  TRANSLATE to CiliumEnvoyConfig  →  agent loads Envoy │
   └───────────────────────────────────────────────────────────────────────────┘
                                                        │  host/path match
                                          ┌─────────────┴─────────────┐
                                          ▼                           ▼
                                   Service foo (ClusterIP)     Service bar (ClusterIP)
                                          ▼                           ▼
                                      pod foo                      pod bar

Ingress is a namespaced object; you pick the controller via the ingressClassName field (the old kubernetes.io/ingress.class annotation is deprecated). Each controller registers an IngressClass; it's a good idea to mark one as default.

Step 1 — enable Cilium's Ingress controller

Cilium was installed with Helm in Article 46. Enabling the ingress controller = a helm upgrade keeping the old values (--reuse-values) plus a few flags. Since the cluster has no cloud load balancer, we expose the door via NodePort (shared mode — one shared cilium-ingress Service for every Ingress):

helm upgrade cilium cilium/cilium --version 1.19.4 --namespace kube-system --reuse-values \
  --set ingressController.enabled=true \
  --set ingressController.default=true \
  --set ingressController.loadbalancerMode=shared \
  --set ingressController.service.type=NodePort \
  --set ingressController.service.insecureNodePort=32080 \
  --set ingressController.service.secureNodePort=32443

Cilium ingress requires kubeProxyReplacement=true (in place since Article 46) and the Envoy L7 proxy (the cilium-envoy DaemonSet is already running). One easy trap when upgrading: helm upgrade only edits the ConfigMap, it doesn't restart the Cilium pods. The agent and operator keep running the old config — cilium-dbg reports enable-ingress-controller actual=false even though the ConfigMap says true. You have to restart both so they reload:

kubectl -n kube-system rollout restart deploy/cilium-operator   # creates CRD CiliumEnvoyConfig + watches Ingress
kubectl -n kube-system rollout restart ds/cilium                # agent loads Envoy listener

Then the IngressClass and the NodePort door appear:

kubectl get ingressclass
kubectl -n kube-system get svc cilium-ingress
NAME               CONTROLLER                     PARAMETERS   AGE
cilium (default)   cilium.io/ingress-controller   <none>       6s

NAME             TYPE       CLUSTER-IP    PORT(S)                      AGE
cilium-ingress   NodePort   10.32.0.191   80:32080/TCP,443:32443/TCP   8s

Step 2 — two backend Services

Stand up two apps that return an identifiable string (the http-echo image returns the same line for any path — handy to see which Service answered):

# foo: "Xin chao tu FOO service" ; bar: "Xin chao tu BAR service" — each app 1 Deployment + 1 Service :80
kubectl create namespace ing-demo
# (full manifest in the repo: foo/bar Deployment image hashicorp/http-echo + Service ClusterIP 80→8080)
kubectl -n ing-demo get svc
NAME   TYPE        CLUSTER-IP    PORT(S)   AGE
foo    ClusterIP   10.32.0.54    80/TCP    5m
bar    ClusterIP   10.32.0.63    80/TCP    5m

Step 3 — Ingress: route by host and path

One Ingress object, combining both routing styles the docs describe. apps.kkloud.local does path fanout (/foo→foo, /bar→bar); while foo.kkloud.local/bar.kkloud.local are host-based virtual hosts (same path /, different host → different Service):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-ingress
  namespace: ing-demo
spec:
  ingressClassName: cilium
  rules:
  - host: apps.kkloud.local          # path fanout
    http:
      paths:
      - {path: /foo, pathType: Prefix, backend: {service: {name: foo, port: {number: 80}}}}
      - {path: /bar, pathType: Prefix, backend: {service: {name: bar, port: {number: 80}}}}
  - host: foo.kkloud.local            # host-based virtual host
    http:
      paths:
      - {path: /, pathType: Prefix, backend: {service: {name: foo, port: {number: 80}}}}
  - host: bar.kkloud.local
    http:
      paths:
      - {path: /, pathType: Prefix, backend: {service: {name: bar, port: {number: 80}}}}

pathType has three kinds: Prefix (prefix match — /foo matches /foo, /foo/, /foo/x), Exact (exact string match), and ImplementationSpecific (up to the controller). Once applied, Cilium translates this Ingress into a CiliumEnvoyConfig — this is the link back to Article 45: the Ingress is the Kubernetes-level declaration, while the thing that actually runs is an Envoy listener with eBPF steering traffic into it:

kubectl -n kube-system get cec cilium-ingress          # CiliumEnvoyConfig Cilium generates itself
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg envoy admin listeners
NAME             AGE
cilium-ingress   2m28s
...
kube-system/cilium-ingress/listener::127.0.0.1:14423   # Envoy listener for ingress

Step 4 — test the routing

Cilium's NodePort does not listen on loopback (eBPF attaches to the ens5 NIC, not lo), so curl the node's IP, not localhost. From a worker, send requests with different Host headers:

IP=10.0.1.20                          # internal IP of worker-0
curl -s -H "Host: apps.kkloud.local" $IP:32080/foo     # path /foo
curl -s -H "Host: apps.kkloud.local" $IP:32080/bar     # path /bar
curl -s -H "Host: foo.kkloud.local"  $IP:32080/        # host foo
curl -s -H "Host: bar.kkloud.local"  $IP:32080/        # host bar
curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Host: nope.kkloud.local" $IP:32080/   # unknown host
curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Host: apps.kkloud.local" $IP:32080/baz # unknown path
Xin chao tu FOO service        # /foo  -> Service foo
Xin chao tu BAR service        # /bar  -> Service bar
Xin chao tu FOO service        # host foo.kkloud.local -> foo
Xin chao tu BAR service        # host bar.kkloud.local -> bar
HTTP 404                        # host matches no rule
HTTP 404                        # path matches no rule

A single door (port 32080), four different paths, resolved entirely at the HTTP layer by host + path. The last two requests return 404 because they match no rule and this Ingress declares no defaultBackend (if it had one, every stray request would land there instead of a 404). We use a fake Host: header because we haven't pointed real DNS yet — in the real world, these hosts are A records pointing at the node/load balancer IP.

🧹 Cleanup

kubectl delete namespace ing-demo        # removes foo, bar, demo-ingress

Delete the demo workloads. Keep Cilium's Ingress controller (ingressController.enabled=true) and the cilium-ingress Service — that's a config flag on an existing CNI, not a separate workload, and Article 49 continues Cilium's service-mesh thread. Manifests at github.com/nghiadaulau/kubernetes-from-scratch, folder 48-ingress.

Wrap-up

Ingress brings HTTP in from outside, routing by host and path to a Service — but only when an Ingress controller is running (creating a bare Ingress object has no effect). We enabled Cilium's built-in controller via helm upgrade --reuse-values (remember to restart operator + agent, since the upgrade only edits the ConfigMap), exposed it via the cilium-ingress NodePort 32080 because the bare-metal cluster has no cloud LB, then tested both routing styles for real: path fanout (/foo→foo, /bar→bar under the same host) and host-based virtual hosts (foo./bar.kkloud.local→the matching Service), with stray host/path returning 404. The key link to Articles 45–46: Cilium translates an Ingress into a CiliumEnvoyConfig and loads it as an Envoy listener, with traffic entering via NodePort steered by eBPF — there's no separate standalone proxy controller. And the foundational decision: because Ingress NGINX is retired (3/2026) and the Ingress API is frozen, we avoid end-of-life software and use a maintained controller.

Ingress being frozen means the more advanced stuff — header-based routing, percentage traffic splitting, separating infrastructure and application roles — will never make it into this API. Article 49 moves to the official successor: Gateway API, also running on Cilium, redesigned from scratch for exactly those needs.