The first time a service stopped resolving in one of my clusters, I spent an evening reading iptables chains. Hundreds of rules, generated by kube-proxy, evaluated top to bottom. I never found the actual problem. I restarted a node and it went away. That bothered me more than the outage did. I was running something I couldn’t read.
That feeling is why I moved to Cilium. It uses eBPF to push networking logic down into the Linux kernel and skips iptables entirely. You get better performance, you can actually see what your traffic is doing, and network policies stop being a guessing game.
This is what I run in my clusters now. I’ll build it up from the simplest piece, so you can stop reading at whatever depth you need and still walk away with something working.
What is eBPF?
Start here, because the rest only makes sense once this clicks. eBPF (extended Berkeley Packet Filter) lets you run sandboxed programs inside the Linux kernel without patching kernel source or loading a kernel module.
flowchart TD
subgraph userspace["User Space"]
APP["Application"]
CILIUM["Cilium Agent"]
end
subgraph kernel["Kernel Space"]
EBPF["eBPF Programs"]
NET["Network Stack"]
HOOKS["Kernel Hooks<br/>(XDP, TC, Socket)"]
end
APP --> NET
CILIUM -->|"loads"| EBPF
EBPF --> HOOKS
HOOKS --> NET
Traditional networking leans on iptables, a chain of rules walked one by one until something matches. eBPF programs get compiled and run directly in the kernel, and they look things up in maps instead of scanning a list. That sounds like a small distinction until you scale it.
Here’s where it bites. Iptables with 10,000 services means a linear scan that gets slower with every service you add. Cilium with 10,000 services does a constant-time map lookup. Same workload, very different behaviour under load. And when something is off, I can inspect the maps directly instead of guessing which of a few hundred rules misfired.
Installing Cilium
The minimum viable version: swap your existing CNI for Cilium and check that it came up.
# Install Cilium CLI
CILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)
curl -L --fail --remote-name-all https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-amd64.tar.gz
sudo tar xzvfC cilium-linux-amd64.tar.gz /usr/local/bin
rm cilium-linux-amd64.tar.gz
# Install Cilium in cluster
cilium install --version 1.15.0
# Verify installation
cilium status --wait
The CLI is fine for a first look, but I don’t run anything by hand twice. Here’s the same install as an ArgoCD Application, so the cluster’s network layer lives in Git like everything else:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cilium
namespace: argocd
spec:
project: default
source:
repoURL: https://helm.cilium.io/
chart: cilium
targetRevision: 1.15.0
helm:
values: |
kubeProxyReplacement: true
k8sServiceHost: "10.0.0.1"
k8sServicePort: "6443"
hubble:
enabled: true
relay:
enabled: true
ui:
enabled: true
operator:
replicas: 2
ipam:
mode: kubernetes
destination:
server: https://kubernetes.default.svc
namespace: kube-system
kube-proxy Replacement
This is the first layer worth turning on. Cilium can replace kube-proxy entirely and implement Kubernetes Services in eBPF, which is how I got rid of those iptables chains that started this whole thing:
# Cilium values
kubeProxyReplacement: true
k8sServiceHost: "10.0.0.1" # API server IP
k8sServicePort: "6443"
What you get:
- No iptables rules for services
- Direct server return for better performance
- Socket-level load balancing, where the decision happens at
connect()instead of on every packet - Maglev hashing for consistent backend selection
Verify it’s working:
kubectl -n kube-system exec ds/cilium -- cilium status | grep KubeProxyReplacement
# KubeProxyReplacement: True [eth0 10.0.0.10 (Direct Routing)]
Network Policies
Now the part that actually changes how you think about security. Cilium speaks standard Kubernetes NetworkPolicy, and it adds its own CiliumNetworkPolicy on top with a lot more reach. Start with the standard one if you’ve never written a policy before.
Kubernetes NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-ingress
namespace: production
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
CiliumNetworkPolicy (Extended)
Once the basics make sense, the native policy is where it gets interesting:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: api-policy
namespace: production
spec:
endpointSelector:
matchLabels:
app: api
ingress:
- fromEndpoints:
- matchLabels:
app: frontend
toPorts:
- ports:
- port: "8080"
protocol: TCP
rules:
http:
- method: "GET"
path: "/api/v1/.*"
- method: "POST"
path: "/api/v1/orders"
Look at the rules.http block. Cilium enforces L7 policies here, so you can allow specific HTTP methods and paths and reject the rest, no service mesh required. A compromised frontend can hit GET /api/v1/... but it can’t suddenly start calling some admin endpoint you never meant to expose. That’s blast radius reduction you can actually read.
DNS-Based Policies
Allow traffic to external services by DNS name:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-external-api
spec:
endpointSelector:
matchLabels:
app: backend
egress:
- toFQDNs:
- matchName: "api.stripe.com"
- matchPattern: "*.amazonaws.com"
toPorts:
- ports:
- port: "443"
Cilium intercepts the DNS queries and updates the policy when the IPs behind those names change, so you write the rule against a name a human understands instead of a CIDR that rotates out from under you.
Cluster-Wide Policies
Apply policies across all namespaces:
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
name: deny-external-by-default
spec:
endpointSelector: {}
egress:
- toEntities:
- cluster
- host
- toFQDNs:
- matchPattern: "*.internal.company.com"
egressDeny:
- toEntities:
- world
This denies internet access by default and only lets internal traffic through unless you explicitly open something. Default-deny egress is one of those things that feels paranoid until the day a dependency you forgot about tries to phone home and you catch it in the logs.
Hubble: Network Observability
Remember the iptables evening I opened with? Hubble is the answer to it. It’s Cilium’s observability layer, and it shows you what your network is actually doing instead of leaving you to infer it from rules.
# Enable Hubble
hubble:
enabled: true
relay:
enabled: true
ui:
enabled: true
metrics:
enabled:
- dns
- drop
- tcp
- flow
- icmp
- http
Hubble CLI
# Install Hubble CLI
export HUBBLE_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/hubble/master/stable.txt)
curl -L --fail --remote-name-all https://github.com/cilium/hubble/releases/download/$HUBBLE_VERSION/hubble-linux-amd64.tar.gz
sudo tar xzvfC hubble-linux-amd64.tar.gz /usr/local/bin
# Port-forward to Hubble Relay
cilium hubble port-forward &
# Observe flows
hubble observe --namespace production
# Filter by pod
hubble observe --pod production/api-xyz123
# Filter by verdict
hubble observe --verdict DROPPED
# Filter by HTTP
hubble observe --protocol http --http-status 500
Hubble UI
Access the visual network flow map:
cilium hubble ui
# Opens browser to http://localhost:12000
The UI shows:
- Service dependency graph
- Real-time traffic flows
- Policy verdicts (allowed/dropped)
- HTTP request/response details
Hubble Metrics
Export to Prometheus:
hubble:
metrics:
serviceMonitor:
enabled: true
enabled:
- dns:query;ignoreAAAA
- drop
- tcp
- flow
- icmp
- http
Useful metrics:
hubble_flows_processed_total, total observed flowshubble_drop_total, dropped packets by reasonhubble_http_requests_total, HTTP requests by method and statushubble_dns_queries_total, DNS queries by type and response
Service Mesh (Without Sidecars)
This is the advanced layer, and the one where Cilium quietly replaces a whole category of tooling. You can get service mesh features without running a sidecar proxy next to every pod:
# Enable L7 proxy
l7Proxy: true
# Enable mutual TLS
authentication:
mutual:
spiffe:
enabled: true
trustDomain: cluster.local
Features available:
- mTLS between services (using SPIFFE identities)
- L7 load balancing with retries
- Traffic management (canary, header-based routing)
- Observability (L7 metrics, distributed tracing)
The thing I care about: no sidecar containers. The eBPF dataplane handles all of it, which cuts the resource overhead and the latency you’d otherwise pay for an Envoy next to every pod. Fewer moving parts to reason about when something breaks at 2am.
apiVersion: cilium.io/v2
kind: CiliumEnvoyConfig
metadata:
name: api-routing
spec:
services:
- name: api
namespace: production
backendServices:
- name: api-v1
namespace: production
- name: api-v2
namespace: production
resources:
- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
# ... Envoy configuration for traffic splitting
BGP for Bare Metal
If you self-host on real hardware like I do, you don’t get a cloud load balancer handed to you. Cilium speaks BGP, so your own network gear can route to pods directly:
# Enable BGP
bgpControlPlane:
enabled: true
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPPeeringPolicy
metadata:
name: rack-bgp
spec:
nodeSelector:
matchLabels:
rack: rack-1
virtualRouters:
- localASN: 65001
exportPodCIDR: true
neighbors:
- peerAddress: "10.0.0.1/32"
peerASN: 65000
Your pods get routable IPs, advertised via BGP to your network infrastructure.
Bandwidth Management
Cilium can enforce bandwidth limits:
apiVersion: v1
kind: Pod
metadata:
annotations:
kubernetes.io/ingress-bandwidth: "10M"
kubernetes.io/egress-bandwidth: "10M"
spec:
containers:
- name: app
image: my-app:latest
This runs in eBPF rather than as tc rules, so it’s more efficient and the limits behave predictably under load.
Encryption
Encrypt every pod-to-pod flow with one block of config:
# WireGuard encryption (recommended)
encryption:
enabled: true
type: wireguard
# Or IPsec
encryption:
enabled: true
type: ipsec
I run WireGuard. It’s faster and simpler, and the kernel module is already there. Reach for IPsec only if you have a compliance certification that specifically demands it.
Verify encryption:
kubectl -n kube-system exec ds/cilium -- cilium status | grep Encryption
# Encryption: Wireguard [NodeEncryption: Disabled, cilium_wg0 (Pubkey: xxx, Port: 51871, Peers: 2)]
Multi-Cluster with Cluster Mesh
Connect multiple Cilium clusters:
# Enable Cluster Mesh on each cluster
cilium clustermesh enable
# Connect clusters
cilium clustermesh connect --destination-context cluster2
Features:
- Global services, the same service reachable from any cluster
- Cross-cluster network policies, so you can allow traffic from cluster1 to cluster2 pods
- Shared identities, giving you consistent security policies across clusters
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-from-cluster1
spec:
endpointSelector:
matchLabels:
app: api
ingress:
- fromEndpoints:
- matchLabels:
io.cilium.k8s.policy.cluster: cluster1
app: frontend
Troubleshooting
Check Endpoint Status
kubectl -n kube-system exec ds/cilium -- cilium endpoint list
Check Policy Verdicts
kubectl -n kube-system exec ds/cilium -- cilium policy get
Debug Dropped Traffic
hubble observe --verdict DROPPED
Check BPF Maps
kubectl -n kube-system exec ds/cilium -- cilium bpf lb list
kubectl -n kube-system exec ds/cilium -- cilium bpf endpoint list
My Production Configuration
Here’s the full picture, the actual config I run. Compare it to the bare cilium install we started with and you can see how far up the stack we climbed:
cilium:
kubeProxyReplacement: true
k8sServiceHost: "10.0.0.1"
k8sServicePort: "6443"
ipam:
mode: kubernetes
# Enable Hubble observability
hubble:
enabled: true
relay:
enabled: true
ui:
enabled: true
metrics:
enabled:
- dns:query
- drop
- tcp
- flow
- http
# WireGuard encryption
encryption:
enabled: true
type: wireguard
# L7 policies
l7Proxy: true
# Operator HA
operator:
replicas: 2
# Resource limits
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 100m
memory: 256Mi
Why each piece is there:
- kube-proxy replacement, because it’s simpler and faster, and it removes the layer I couldn’t read
- WireGuard encryption, because pod-to-pod traffic should be encrypted and this costs me almost nothing to turn on
- Hubble metrics, exported to Prometheus so I get alerts instead of surprises
- L7 proxy, for HTTP-aware policies without dragging in a separate mesh
Why Cilium?
To be fair, the traditional CNI plugins work. Calico, Flannel, Weave, they all route packets and plenty of clusters run on them happily. My problem with them is what they’re built on: iptables, a tool from 1998 that I end up reading by hand when things go sideways.
Cilium lines up with how I want to run infrastructure. The dataplane is programmable eBPF in the kernel. Policies are identity-based, so they describe what a workload is rather than which IP it happens to have today. The traffic is observable through Hubble, so I see flows instead of inferring them from rule order. And service mesh, BGP, and encryption all come from one tool instead of four.
That last point matters more than it looks. As I argued in Sovereign Infrastructure, I want to understand what I’m running, and a black box in the network path is the worst kind of splinter. Cilium gives me the visibility iptables never could, catches L7 attacks that L3/L4 rules miss, and runs faster while doing it. The fact that I can read what it’s doing is the whole reason I switched.
iptables did its job for decades. But I want a network layer I can inspect, not one I restart and pray over. eBPF gives me that, and Cilium is how I put it to work.
