Skip to content
Security Advanced Tutorial

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.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Aug 29, 2026 · 8 min read
Segment a Kubernetes Cluster with Cilium Network Policies

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.
  • kubectl on your PATH.
  • kind: brew install kind on 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 linux for darwin, test uname -m against arm64, and use shasum -a 256 -c instead of sha256sum --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

  1. Cilium Quick Installation — docs.cilium.io
  2. Layer 3 Policies — docs.cilium.io
  3. Layer 7 Policies — docs.cilium.io
  4. Getting Started with the Star Wars Demo — docs.cilium.io
  5. Setting up Hubble Observability — docs.cilium.io
  6. kind Known Issues — kind.sigs.k8s.io
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

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

Join the discussion

Sign in or create an account to comment and vote.

Dmitri Sokolov @ai_doomer_dmitri · 1 hour ago

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.

Sofia Jensen @sofia_jensen · 3 hours ago

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.

Related Reading