Segment a Kubernetes Cluster with Cilium Network Policies
Lock a namespace to default-deny with Cilium, then reopen DNS, one port, and one HTTP path.
What you'll build
A three-node local cluster running Cilium as its CNI, with one namespace locked down to default-deny and then reopened one rule at a time: DNS only, then a single TCP port between two labeled pods, then a single HTTP method and path on that port. You'll watch each drop happen in Hubble as you go.
Prerequisites
Verified against Cilium 1.20.1 (August 2026 patch release), cilium CLI 0.19+, Hubble CLI from the current stable channel, and kind 0.33.0, which ships Kubernetes 1.37 node images by default.
- Docker running locally. Docker Desktop on macOS or the Docker Engine on Linux both work.
kubectlon your PATH.- kind:
brew install kindon macOS, or on Linux:
[ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64
[ $(uname -m) = aarch64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-arm64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind
- The cilium CLI. Linux shown; on macOS swap
linuxfordarwin, testuname -magainstarm64, and useshasum -a 256 -cinstead ofsha256sum --check:
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
sudo tar xzvfC cilium-linux-${CLI_ARCH}.tar.gz /usr/local/bin
rm cilium-linux-${CLI_ARCH}.tar.gz{,.sha256sum}
- The Hubble CLI, same pattern:
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}
Step 1: Create a kind cluster with no CNI
kind installs kindnet by default. Turn it off so Cilium owns pod networking; nodes will sit in NotReady until Cilium comes up, which is expected.
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cilium-lab
nodes:
- role: control-plane
- role: worker
- role: worker
networking:
disableDefaultCNI: true
kind create cluster --config=kind-config.yaml
Step 2: Install Cilium and enable Hubble
The CLI auto-detects kind and picks sane IPAM settings. --wait blocks until every component reports healthy.
cilium install --version 1.20.1
cilium status --wait
cilium hubble enable
cilium status --wait
The second cilium status should now show Hubble Relay: OK alongside Cilium: OK and Operator: OK. Confirm the Hubble CLI can reach relay; -P opens a port-forward to relay on localhost:4245 for the life of the command, so you don't need a separate cilium hubble port-forward:
hubble status -P
Step 3: Deploy a workload and confirm it's wide open
Cilium's Star Wars demo gives you a deathstar service (two replicas, org=empire, class=deathstar), a tiefighter pod (org=empire) and an xwing pod (org=alliance). Put it in its own namespace so the policies you write are scoped to it.
kubectl create namespace starwars
kubectl -n starwars create -f https://raw.githubusercontent.com/cilium/cilium/1.20.1/examples/minikube/http-sw-app.yaml
kubectl -n starwars wait --for=condition=Ready pod --all --timeout=120s
With no policy in place, everything can land:
kubectl -n starwars exec xwing -- curl -s -XPOST deathstar.starwars.svc.cluster.local/v1/request-landing
kubectl -n starwars exec tiefighter -- curl -s -XPOST deathstar.starwars.svc.cluster.local/v1/request-landing
Both print Ship landed.
Step 4: Default-deny the namespace
A CiliumNetworkPolicy puts an endpoint into default-deny for a direction the moment a rule selects it and contains that direction's section. An empty endpointSelector selects every pod in the namespace, and an empty rule (- {}) allows nothing, so this shuts off all ingress and egress:
# 01-default-deny.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: starwars
spec:
endpointSelector: {}
ingress:
- {}
egress:
- {}
kubectl apply -f 01-default-deny.yaml
kubectl -n starwars exec tiefighter -- curl -s --max-time 5 -XPOST deathstar.starwars.svc.cluster.local/v1/request-landing
curl now fails at name resolution, because the pod can't reach CoreDNS in kube-system any more. Depending on how fast the resolver gives up you'll see either curl: (28) Resolving timed out after 5000 milliseconds or curl: (6) Could not resolve host: deathstar.starwars.svc.cluster.local. Hubble shows why:
hubble observe -P --namespace starwars --verdict DROPPED --last 5
Each dropped UDP/53 packet to a kube-system/coredns-* pod is tagged Policy denied DROPPED.
Step 5: Allow DNS
Every pod in the namespace needs egress to kube-dns, and nothing else. Cross-namespace targets must name the namespace explicitly with the k8s:io.kubernetes.pod.namespace label; unprefixed labels in toEndpoints only match the policy's own namespace. The rules.dns block makes Cilium parse the queries, which Hubble then shows you as L7 DNS flows.
# 02-allow-dns.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-dns-egress
namespace: starwars
spec:
endpointSelector: {}
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
"k8s:k8s-app": kube-dns
toPorts:
- ports:
- port: "53"
protocol: ANY
rules:
dns:
- matchPattern: "*"
kubectl apply -f 02-allow-dns.yaml
kubectl -n starwars exec tiefighter -- curl -s --max-time 5 -XPOST deathstar.starwars.svc.cluster.local/v1/request-landing
The name resolves now, but the connection itself still dies: curl: (28) Connection timed out after 5001 milliseconds. The TCP SYN is dropped on tiefighter's egress and would be dropped again on deathstar's ingress.
Step 6: Allow tiefighter to reach deathstar on port 80
Cilium enforces at both ends, so a pod-to-pod allow is two rules: egress on the client, ingress on the server. Only pods carrying org=empire, class=tiefighter get through; xwing stays blocked.
# 03-allow-tiefighter-to-deathstar.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: tiefighter-egress-to-deathstar
namespace: starwars
spec:
endpointSelector:
matchLabels:
org: empire
class: tiefighter
egress:
- toEndpoints:
- matchLabels:
org: empire
class: deathstar
toPorts:
- ports:
- port: "80"
protocol: TCP
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: deathstar-ingress-from-tiefighter
namespace: starwars
spec:
endpointSelector:
matchLabels:
org: empire
class: deathstar
ingress:
- fromEndpoints:
- matchLabels:
org: empire
class: tiefighter
toPorts:
- ports:
- port: "80"
protocol: TCP
rules:
http:
- method: POST
path: /v1/request-landing
The rules.http block on the ingress side is the L7 layer: Cilium hands port 80 traffic for deathstar to its Envoy proxy, which only passes POST /v1/request-landing. Anything else on the port gets an HTTP 403 rather than a dropped packet.
kubectl apply -f 03-allow-tiefighter-to-deathstar.yaml
Verify it works
Run the four checks below. Order matters only for reading the Hubble output at the end.
kubectl -n starwars exec tiefighter -- curl -s -XPOST deathstar.starwars.svc.cluster.local/v1/request-landing
Prints Ship landed.
kubectl -n starwars exec tiefighter -- curl -s -XPUT deathstar.starwars.svc.cluster.local/v1/exhaust-port
Prints Access denied. Same pod, same port, wrong method and path; the L7 rule rejected it.
kubectl -n starwars exec xwing -- curl -s --max-time 5 -XPOST deathstar.starwars.svc.cluster.local/v1/request-landing
Prints curl: (28) Connection timed out after 5001 milliseconds (the millisecond count varies). xwing resolves the name fine but has no egress rule beyond DNS.
hubble observe -P --namespace starwars --verdict DROPPED --last 10
You should see two kinds of drops, with your own timestamps, ports and identity IDs:
starwars/tiefighter:39412 (ID:21377) -> starwars/deathstar-8c4c77fb7-n6t8b:80 (ID:14109) http-request DROPPED (HTTP/1.1 PUT http://deathstar.starwars.svc.cluster.local/v1/exhaust-port)
starwars/xwing:47210 (ID:30581) <> starwars/deathstar-8c4c77fb7-n6t8b:80 (ID:14109) Policy denied DROPPED (TCP Flags: SYN)
Finally, confirm enforcement is on for every endpoint in the namespace:
kubectl -n kube-system exec ds/cilium -- cilium-dbg endpoint list
Rows whose LABELS include k8s:io.kubernetes.pod.namespace=starwars should read Enabled in both the POLICY (ingress) ENFORCEMENT and POLICY (egress) ENFORCEMENT columns. Pods in other namespaces still show Disabled, since no policy selects them.
Troubleshooting
no matches for kind "CiliumNetworkPolicy" in version "cilium.io/v2" when applying a policy. Full text is error: resource mapping not found for name: "default-deny" namespace: "starwars" from "01-default-deny.yaml": no matches for kind "CiliumNetworkPolicy" in version "cilium.io/v2" ensure CRDs are installed first. Cilium's CRDs aren't registered, which means cilium install didn't finish or you're pointed at the wrong kubeconfig context. Run kubectl config current-context (it should be kind-cilium-lab) and cilium status.
Pods stuck in ContainerCreating or crash-looping with too many open files. kind nodes exhaust the host's inotify limits, especially on Linux with three nodes plus Cilium and Envoy DaemonSets. Raise them on the host and the pods recover:
sudo sysctl fs.inotify.max_user_watches=524288
sudo sysctl fs.inotify.max_user_instances=512
Put the same two lines in /etc/sysctl.conf to make it stick.
curl: (28) Resolving timed out after you've applied the DNS policy. The toEndpoints labels must carry the k8s: prefix and target kube-system. A policy that says k8s-app: kube-dns without the namespace label matches nothing, because an unprefixed selector is scoped to starwars. Check with hubble observe -P --namespace starwars --verdict DROPPED --last 5: if drops still point at kube-system/coredns-*:53, the DNS rule isn't matching.
tiefighter gets Access denied on /v1/request-landing. Method and path in rules.http are exact. A trailing slash, a lowercase post, or a -XGET in your test command all fail the match. Compare the request Hubble logged (hubble observe -P --namespace starwars --protocol http --last 5) against the rule.
Next steps
Two directions from here. First, replace the per-namespace default-deny with a CiliumClusterwideNetworkPolicy and roll it out safely by setting enableDefaultDeny: { ingress: false, egress: false } on the first pass, so you can audit drops in Hubble before flipping enforcement on. Second, extend the egress side beyond the cluster: toFQDNs rules with matchName or matchPattern let you allow api.github.com and nothing else, and they build on the same kube-dns rule you wrote in Step 5. The Cilium docs on Layer 3 policies and DNS-based policies cover both. When you're done, kind delete cluster --name cilium-lab removes everything.
Sources & further reading
- Cilium Quick Installation — docs.cilium.io
- Layer 3 Policies — docs.cilium.io
- Layer 7 Policies — docs.cilium.io
- Getting Started with the Star Wars Demo — docs.cilium.io
- Setting up Hubble Observability — docs.cilium.io
- kind Known Issues — kind.sigs.k8s.io
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 2
worth testing what happens when your app gets updated and suddenly needs an extra port or endpoint—these policies tend to become a maintenance burden once you're managing dozens of services. also curious if the article touches on the observability gap: dropped packets look clean in hubble, but debugging why a legitimate request failed in production when you've got this granular is its own beast.
nice walkthrough, but the gotcha nobody mentions: those L7 policies (HTTP method/path) need Envoy sidecars or eBPF socket-level inspection, which means either adding overhead or having your actual enforcement silently degrade to L4 if the cilium agent can't see the payload. Fun times debugging why "that rule should've blocked this" in production.