Kubernetes ships with a flat, default-allow network model: every pod can reach every other pod unless something intervenes. Moving to zero trust means replacing that implicit trust with label-driven microsegmentation, and NetworkPolicy objects are the API for expressing it. Standard Kubernetes only defines the policy schema — enforcement is delegated to the CNI. The lab below runs on Calico, which intercepts pod traffic and applies both ingress and egress rules against a three-tier Frontend ⭢ Backend ⭢ Database stack.

Kubernetes network policy diagram

Building the Target Application

A dedicated namespace hosts the workloads, each carrying the labels that will later act as firewall identity.

1

2

3

4

5

6

7

8

kubectl create namespace production-app

# CREATE FRONTEND POD

kubectl run frontend --image=nginx --labels=tier=frontend -n  production-app

# CREATE BACKEND POD

kubectl run backend --image=nginx --labels=tier=backend -n  production-app

# CREATE DATABASE POD

kubectl run database --image=postgres:18 --labels=tier=database -n  production-app \

  --env="POSTGRES_DB=myapp" --env="POSTGRES_USER=appuser" --env="POSTGRES_PASSWORD=securepass123"

Confirm the pods and their labels:

1

2

3

4

5

6

kubectl get pods -n production-app --show-labels

NAME       READY   STATUS    RESTARTS   AGE     LABELS

backend    1/1     Running   0          3h6m    tier=backend

database   1/1     Running   0          3h12m   tier=database

frontend   1/1     Running   0          3h6m    tier=frontend

Publish the pods behind ClusterIP Services so they resolve and connect by name:

1

2

3

kubectl expose pod frontend --port=80 --target-port=80 -n production-app

kubectl expose pod backend --port=80 --target-port=80 -n production-app

kubectl expose pod database --port=5432 --target-port=5432 -n production-app

Closing the Namespace by Default

Nothing is restricted until a policy selects it. The starting point is therefore a namespace-wide default deny: a NetworkPolicy whose podSelector: {} matches every pod in the namespace and drops all inbound traffic that is not explicitly authorized.

default-deny-ingress.yaml:

1

2

3

4

5

6

7

8

9

apiVersion: networking.k8s.io/v1

kind: NetworkPolicy

metadata:

  name: default-deny-ingress

  namespace: production-app

spec:

  podSelector: {}

  policyTypes:

  - Ingress

Opening the Ingress Paths

With the namespace sealed, each legitimate flow gets a narrow hole, keyed on the source pod's labels.

Frontend to Backend

The backend accepts connections only from pods labeled tier: frontend, and only on TCP port 80.

backend-allow-frontend.yaml:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

apiVersion: networking.k8s.io/v1

kind: NetworkPolicy

metadata:

  name: backend-allow-frontend

  namespace: production-app

spec:

  podSelector:

    matchLabels:

      tier: backend

    policyTypes:

    - Ingress

    ingress:

    - from:

      - podSelector:

          matchLabels:

            tier: frontend

        ports:

        - protocol: TCP

          port: 80

Backend to Database

The data tier is tighter still: only the backend may connect, and only on PostgreSQL's default port, 5432.

database-allow-backend.yaml:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

apiVersion: networking.k8s.io/v1

kind: NetworkPolicy

metadata:

  name: database-allow-backend

  namespace: production-app

spec:

  podSelector:

    matchLabels:

      tier: database

  policyTypes:

    - Ingress

  ingress:

  - from:

    - podSelector:

        matchLabels:

          tier: backend

    ports:

    - protocol: TCP

      port: 5432

Constraining Egress

Ingress rules stop inbound attacks on a service; egress rules stop a compromised pod from reaching outward, whether for lateral movement or for exfiltration.

Frontend Egress

frontend-allow-egress.yaml:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

apiVersion: networking.k8s.io/v1

kind: NetworkPolicy

metadata:

  name: frontend-allow-egress

  namespace: production-app

spec:

  podSelector:

    matchLabels:

      tier: frontend

  policyTypes:

    - Egress

  egress:

  - to:

    - podSelector:

        matchLabels:

          tier: backend

    ports:

    - protocol: TCP

      port: 80

  - to:

    - podSelector:

        matchLabels:

          kubernetes.io/metadata.name: kube-system

    ports:

    - protocol: UDP

      port: 53

    - protocol: TCP

      port: 53

Backend Egress

backend-allow-egress.yaml:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

apiVersion: networking.k8s.io/v1

kind: NetworkPolicy

metadata:

  name: backend-allow-egress

  namespace: production-app

spec:

  podSelector:

    matchLabels:

      tier: backend

  policyTypes:

    - Egress

  egress:

  - to:

    - podSelector:

        matchLabels:

          tier: database

    ports:

    - protocol: TCP

      port: 5432

  - to:

    - podSelector:

        matchLabels:

          kubernetes.io/metadata.name: kube-system

    ports:

    - protocol: UDP

      port: 53

    - protocol: TCP

      port: 53

Note: DNS is the easily missed piece of egress security. Both egress policies carry a second to: block. Once egress is restricted, cluster DNS resolution against CoreDNS stops working unless UDP and TCP port 53 to the kube-system namespace are explicitly permitted. Service discovery fails silently otherwise.

External APIs and IP Blocks

Some workloads need to reach external APIs while a known suspicious address inside the allowed range must stay unreachable.

frontend-allow-external-api.yaml:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

apiVersion: networking.k8s.io/v1

kind: NetworkPolicy

metadata:

  name: frontend-allow-external-api

  namespace: production-app

spec:

  podSelector:

    matchLabels:

      tier: frontend

  policyTypes:

    - Egress

  egress:

  - to:

    - ipBlock:

        cidr: 203.0.113.0/24

        except:

        - 203.0.113.1/32

    ports:

    - protocol: TCP

      port: 443

  - to:

    - namespaceSelector:

        matchLabels:

          kubernetes.io/metadata.name: kube-system

    ports:

    - protocol: UDP

      port: 53

    - protocol: TCP

      port: 53

Note: when a second policy selects the same pods, Kubernetes combines both policies with OR logic — the union of their permissions applies.

Verifying the Segmentation

Validation means probing both the paths that should work and the ones that should not.

Breaking the Default Deny

A label-less test pod is created inside the namespace to confirm the baseline is closed.

1

2

3

4

5

6

7

kubectl run test-pod --image=nicolaka/netshoot -n production-app -- sleep 3600

kubectl exec -n production-app test-pod -- curl --max-time 3 backend

Output:

curl: (28) Connection timed out after 3000 milliseconds

command terminated with exit code 28

Blocked by the default deny policy: the backend only accepts pods bearing tier=frontend.

Reaching In From Another Namespace

1

2

3

4

5

6

7

kubectl run external-test --image=nicolaka/netshoot -n default -- sleep 3600

kubectl exec external-test -n default -- curl --max-time 3 backend.production-app.svc.cluster.local

Output:

curl: (28) Connection timed out after 3002 milliseconds

command terminated with exit code 28

Blocked, since the connection originates in the default namespace.

The Authorized Frontend ⭢ Backend Hop

1

2

3

4

5

6

7

8

9

10

11

kubectl exec frontend -n production-app -- curl --max-time 3 backend

Output:

...

<!DOCTYPE html>

<html>

<head>

<title>Welcome to nginx!</title>

...

<h1>Welcome to nginx!</h1>

...

Both sides agree: the frontend egress policy permits pods labeled tier=backend on port 80, and the backend ingress policy permits pods labeled tier=frontend on port 80.

Port-Level Enforcement at the Backend

1

2

3

4

5

kubectl exec frontend -n production-app -- curl --max-time 3 backend:8080

Output:

curl: (28) Connection timed out after 3001 milliseconds

command terminated with exit code 28

Failed even though the label match was valid — NetworkPolicy governs the port as well as the peer, and only port 80 is open.

Backend to Database

Bash's built-in /dev/tcp makes it possible to probe the TCP connection without curl.

1

2

3

4

kubectl exec backend -n production-app -- timeout 3 bash -c 'cat < /dev/null > /dev/tcp/database/5432' && echo "Connection succeessful" || echo "Connection failed"

Output:

Connection succeessful

The backend egress policy allows the database pod on port 5432, and the database ingress policy accepts it.

Port-Level Enforcement at the Database

1

2

3

4

kubectl exec backend -n production-app --timeout 3 bash -c 'cat < /dev/null > /dev/tcp/database/80' && echo "Connection succeessful" || echo "Connection failed"

Output:

Connection failed

Dropped: the database ingress policy admits port 5432 only, so port 80 goes nowhere.

Blocking the Lateral Shortcut

A compromised frontend must not be able to skip the application tier and talk to data directly.

1

2

3

4

kubectl exec frontend -n production-app --timeout 3 bash -c 'cat < /dev/null > /dev/tcp/database/5432' && echo "Connection succeessful" || echo "Connection failed"

Output:

Connection failed

Two independent policies close this path. The frontend egress policy permits outbound traffic only to tier: backend on port 80, and the database ingress policy accepts only tier: backend sources.

What the Lab Demonstrates

  • Labels are identity. Every pod-to-pod and namespace-to-namespace decision keys off labels, so RBAC over label mutation matters as much as RBAC over policies — changing a label can defeat a firewall.
  • Traffic needs permission from both ends. In a locked-down cluster, a flow succeeds only when the source has an egress rule and the destination has an ingress rule.
  • CoreDNS is part of the policy surface. Egress policies must include UDP/TCP port 53 access to kube-system, or service discovery breaks without an obvious error.