Cilium is a Container Network Interface (CNI) plugin for Kubernetes that uses eBPF in the Linux kernel to route pod traffic, load balance Services and enforce network policies, instead of long iptables chains. In this tutorial you will install Cilium with Helm on a kubeadm cluster running Ubuntu 24.04, let it replace kube-proxy, restrict traffic between pods with an HTTP-aware policy and use Hubble to see which flows are allowed and dropped.

Prerequisites

To follow this tutorial, you will need:

  • A Kubernetes cluster (1.30 or newer) built with kubeadm on Ubuntu 24.04 nodes, for example CubePath VPS instances with at least 2 vCPUs and 4 GB of RAM each. Ubuntu 24.04 ships kernel 6.8, which supports every feature used here.
  • The cluster initialized without a CNI and without kube-proxy. With kubeadm, pass --skip-phases=addon/kube-proxy to kubeadm init. Until a CNI is installed, nodes report NotReady and CoreDNS stays Pending, which is expected.
  • kubectl and Helm 3 installed on the machine you manage the cluster from, with a working kubeconfig.
  • The IP address and port of the Kubernetes API server (the address you passed as --control-plane-endpoint or the control plane node IP, port 6443). This guide calls it your_api_server_ip.
  • If a host firewall runs on the nodes, these ports open between nodes: 8472/udp (VXLAN), 4240/tcp (health checks), 4244/tcp (Hubble) and, for Step 6, 51871/udp (WireGuard).

Step 1 - Installing the Cilium CLI

The cilium CLI checks the status of the installation, runs connectivity tests and manages Hubble. Download the latest stable release and verify its checksum:

CILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)
CLI_ARCH=amd64
if [ "$(uname -m)" = "aarch64" ]; then CLI_ARCH=arm64; fi
curl -L --fail --remote-name-all "https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-${CLI_ARCH}.tar.gz"{,.sha256sum}
sha256sum --check "cilium-linux-${CLI_ARCH}.tar.gz.sha256sum"
cilium-linux-amd64.tar.gz: OK

Extract the binary to /usr/local/bin and remove the archive:

sudo tar xzvfC "cilium-linux-${CLI_ARCH}.tar.gz" /usr/local/bin
rm "cilium-linux-${CLI_ARCH}.tar.gz"{,.sha256sum}

Confirm the CLI runs:

cilium version --client
cilium-cli: v0.18.x compiled with go1.24.x on linux/amd64

Step 2 - Installing Cilium with Helm

Add the official Helm repository:

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

Because kube-proxy is not running, Cilium cannot reach the API server through the kubernetes Service ClusterIP during bootstrap. You give it the real address with k8sServiceHost and k8sServicePort, and set kubeProxyReplacement=true so Cilium implements Services in eBPF. Replace your_api_server_ip with your API server address:

helm install cilium cilium/cilium \
  --namespace kube-system \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=your_api_server_ip \
  --set k8sServicePort=6443 \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true

This installs the latest chart version. To pin a specific release, list them with helm search repo cilium/cilium --versions and add --version to the command.

Wait until every component reports healthy:

cilium status --wait
Cilium:             OK
Operator:           OK
Envoy DaemonSet:    OK
Hubble Relay:       OK
ClusterMesh:        disabled

DaemonSet              cilium             Desired: 3, Ready: 3/3, Available: 3/3
Deployment             cilium-operator    Desired: 1, Ready: 1/1, Available: 1/1
Deployment             hubble-relay       Desired: 1, Ready: 1/1, Available: 1/1
Deployment             hubble-ui          Desired: 1, Ready: 1/1, Available: 1/1

The nodes now switch to Ready and CoreDNS starts:

kubectl get nodes
kubectl -n kube-system get pods -l k8s-app=kube-dns
NAME       STATUS   ROLES           AGE   VERSION
k8s-cp1    Ready    control-plane   25m   v1.33.4
k8s-w1     Ready    <none>          22m   v1.33.4
k8s-w2     Ready    <none>          22m   v1.33.4
NAME                       READY   STATUS    RESTARTS   AGE
coredns-674b8bbfcf-8qv2x   1/1     Running   0          25m
coredns-674b8bbfcf-rk7nz   1/1     Running   0          25m

Check that the kube-proxy replacement is active. Inside the agent pods, the debugging CLI is called cilium-dbg:

kubectl -n kube-system exec ds/cilium -- cilium-dbg status | grep KubeProxyReplacement
KubeProxyReplacement:    True   [eth0   203.0.113.10 fe80::be24:11ff:fe4a:1 (Direct Routing)]

Step 3 - Running the connectivity test

The CLI ships an end-to-end test suite that deploys client and server pods and checks pod-to-pod, pod-to-Service, node port, DNS and policy behavior. It takes several minutes:

cilium connectivity test
[cilium-test-1] All 70 tests (620 actions) successful, 0 tests skipped, 0 scenarios skipped.

The number of tests varies between versions. When it finishes, delete the namespace the test created (list it with kubectl get ns | grep cilium-test):

kubectl delete namespace cilium-test-1

Step 4 - Enforcing L3, L4 and L7 network policies

Cilium enforces standard Kubernetes NetworkPolicy objects and adds its own CiliumNetworkPolicy, which can also filter on HTTP methods and paths. Create a small test setup: an nginx backend and two client pods with different labels.

kubectl create namespace demo
kubectl -n demo create deployment backend --image=nginx
kubectl -n demo expose deployment backend --port=80
kubectl -n demo run frontend --image=curlimages/curl --labels=app=frontend -- sleep infinity
kubectl -n demo run other --image=curlimages/curl --labels=app=other -- sleep infinity

Wait for the pods to be ready, then confirm both clients can reach the backend before any policy exists:

kubectl -n demo wait --for=condition=Ready pod --all --timeout=120s
kubectl -n demo exec frontend -- curl -s -o /dev/null -w '%{http_code}\n' http://backend/
kubectl -n demo exec other -- curl -s -o /dev/null -w '%{http_code}\n' http://backend/
200
200

Now write a policy that selects the backend pods and only allows pods labeled app=frontend to send GET / on port 80. Once a pod is selected by a policy with an ingress section, all other ingress traffic to it is denied.

nano backend-policy.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: backend-allow-frontend
  namespace: demo
spec:
  endpointSelector:
    matchLabels:
      app: backend
  ingress:
    - fromEndpoints:
        - matchLabels:
            app: frontend
      toPorts:
        - ports:
            - port: "80"
              protocol: TCP
          rules:
            http:
              - method: GET
                path: "/"

The path field is a regular expression matched against the full request path, so "/" allows only the root page. Apply the policy:

kubectl apply -f backend-policy.yaml

Test the three cases. The frontend can still load /:

kubectl -n demo exec frontend -- curl -s -o /dev/null -w '%{http_code}\n' http://backend/
200

Any other path from the frontend is rejected at L7 by Cilium's embedded Envoy proxy with 403 Forbidden:

kubectl -n demo exec frontend -- curl -s -o /dev/null -w '%{http_code}\n' http://backend/index.html
403

The other pod is dropped at L3/L4, so its request times out:

kubectl -n demo exec other -- curl -s --max-time 5 -o /dev/null -w '%{http_code}\n' http://backend/
000
command terminated with exit code 28

List the policies Cilium has imported with kubectl get ciliumnetworkpolicies -n demo.

Step 5 - Observing traffic with Hubble

Hubble records every flow that passes through Cilium, with the policy verdict. Install the Hubble CLI the same way as the Cilium CLI:

HUBBLE_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/hubble/main/stable.txt)
HUBBLE_ARCH=amd64
if [ "$(uname -m)" = "aarch64" ]; then HUBBLE_ARCH=arm64; fi
curl -L --fail --remote-name-all "https://github.com/cilium/hubble/releases/download/${HUBBLE_VERSION}/hubble-linux-${HUBBLE_ARCH}.tar.gz"{,.sha256sum}
sha256sum --check "hubble-linux-${HUBBLE_ARCH}.tar.gz.sha256sum"
sudo tar xzvfC "hubble-linux-${HUBBLE_ARCH}.tar.gz" /usr/local/bin
rm "hubble-linux-${HUBBLE_ARCH}.tar.gz"{,.sha256sum}

The Hubble Relay you enabled in Step 2 aggregates flows from all nodes. Forward it to localhost:4245 in the background and check the connection:

cilium hubble port-forward &
hubble status
Healthcheck (via localhost:4245): Ok
Current/Max Flows: 12,285/12,285 (100.00%)
Flows/s: 41.37
Connected Nodes: 3/3

Repeat the blocked request from the other pod, then ask Hubble for dropped flows in the demo namespace:

kubectl -n demo exec other -- curl -s --max-time 5 http://backend/
hubble observe --namespace demo --verdict DROPPED --last 5
Sep 25 10:14:02.118: demo/other:51720 (ID:23411) <> demo/backend-5d8c9f7b6-xk2lp:80 (ID:9102) policy-verdict:none INGRESS DENIED (TCP Flags: SYN)
Sep 25 10:14:02.118: demo/other:51720 (ID:23411) <> demo/backend-5d8c9f7b6-xk2lp:80 (ID:9102) Policy denied DROPPED (TCP Flags: SYN)

To see the L7 decisions for the frontend, including the rejected /index.html request, filter by HTTP:

hubble observe --namespace demo --protocol http --last 10

For a graphical service map, open the Hubble UI. This command forwards it to http://localhost:12000 and tries to open a browser:

cilium hubble ui

If you manage the cluster over SSH, forward port 12000 from your workstation (ssh -L 12000:localhost:12000 your_user@your_server_ip) and open the URL locally.

Step 6 - Encrypting pod traffic with WireGuard (optional)

Traffic between pods on different nodes travels unencrypted by default. Cilium can encrypt it transparently with WireGuard, with no change to the applications. Enable it on the existing release:

helm upgrade cilium cilium/cilium \
  --namespace kube-system \
  --reuse-values \
  --set encryption.enabled=true \
  --set encryption.type=wireguard
kubectl -n kube-system rollout restart ds/cilium
kubectl -n kube-system rollout status ds/cilium

Verify encryption is active on an agent:

kubectl -n kube-system exec ds/cilium -- cilium-dbg encrypt status
Encryption: Wireguard
Interface: cilium_wg0
	Public key: 9W8kW2yA...=
	Number of peers: 2

The number of peers should be the number of nodes minus one.

Troubleshooting

Nodes stay NotReady and Cilium pods crash with API server timeouts. The k8sServiceHost value is wrong or unreachable from the nodes. Check the agent logs with kubectl -n kube-system logs ds/cilium and fix the value with helm upgrade cilium cilium/cilium -n kube-system --reuse-values --set k8sServiceHost=correct_ip.

Services work on some nodes but not others after installing. kube-proxy is still running and its iptables rules conflict with Cilium. Check with kubectl -n kube-system get ds kube-proxy. Either remove it (kubectl -n kube-system delete ds kube-proxy and kubectl -n kube-system delete cm kube-proxy, then reboot or flush the KUBE-* iptables chains on each node) or reinstall Cilium with kubeProxyReplacement=false.

Pods lose DNS after applying an egress policy. A CiliumNetworkPolicy with an egress section denies everything not listed, including DNS. Add an egress rule to k8s-app: kube-dns pods in kube-system on port 53/UDP, and confirm with hubble observe --namespace your_namespace --protocol dns.

hubble status cannot connect. The port-forward is not running or Relay is down. Check kubectl -n kube-system get deploy hubble-relay and restart it with kubectl -n kube-system rollout restart deploy/hubble-relay.

Conclusion

You installed Cilium as the CNI of a kubeadm cluster, replaced kube-proxy with eBPF load balancing, restricted access to a backend down to a single HTTP method and path, and traced allowed and denied flows with Hubble. From here you can enable Hubble metrics for Prometheus (hubble.metrics.enabled), use toFQDNs rules to control egress by DNS name, or connect several clusters with Cilium Cluster Mesh.