Securing Microservices with Kubernetes Network Policies
Why default pod-to-pod openness is no longer safe, and how policy-as-code helps you tighten it without slowing teams down

I still remember the first time a security engineer slid a ticket into our backlog titled “Reduce lateral movement in the cluster.” It sounded serious, but we were shipping features fast and our Kubernetes setup worked—pods talked to pods, services resolved, CI deployed. The default overlay network let anything talk to anything. That openness made early development easy, but it also meant that if a single compromised service leaked credentials or allowed command execution, an attacker had a wide-open highway.
We didn’t need a new tool or a consultant to start making things better. We needed rules that reflect our architecture: frontend can reach backend, backend can reach the database, and nothing else. Kubernetes Network Policies give you that power in a way that’s declarative, testable, and incremental. This post walks through what they are, how to use them, and what I’ve learned putting them to work in real clusters.
Where Kubernetes Network Policies fit today
Kubernetes Network Policies are a built-in way to control pod-to-pod traffic at the IP and port level. They’re not a service mesh, not a firewall appliance, and not a cloud provider security group. They are a cluster-native policy layer that sits closest to your workloads and moves with them.
- Most production teams use them to enforce a baseline: allow only the traffic that is necessary, deny everything else by default.
- Platform engineers define default-deny policies across namespaces and provide easy templates for app teams.
- They complement other controls: TLS for encryption, RBAC for access control, and admission policies for governance.
- Compared to cloud provider security groups, Network Policies are portable across clusters and cloud vendors. Compared to service meshes, they are lighter and simpler to reason about for basic allow/deny needs.
In practice, teams typically adopt them in phases:
- Create a default-deny policy in namespaces to stop the “allow by default” behavior.
- Add explicit allow rules per service, starting with the most sensitive workloads.
- Measure impact using dry-run modes and observability tools before enforcing.
- Integrate policy checks into CI and cluster bootstrapping to prevent regressions.
If you’re new to the idea, the official Kubernetes documentation is a good anchor: Kubernetes Network Policies. It’s a mature feature, but it’s not automatic. You still need a plugin that implements the policy enforcement, like Calico, Cilium, or other CNIs that support it. If your cluster’s CNI doesn’t enforce Network Policies, the policies you create won’t have any effect.
Core concepts: what Network Policies control
A NetworkPolicy applies to pods using a pod selector and defines ingress and egress rules. Each rule can specify:
- Peer types: other pods (via namespace and pod selectors), IP blocks, or both.
- Ports: TCP, UDP, or SCTP on specific numbers.
- Policy types: ingress, egress, or both.
The default behavior without any policy is “allow all.” Once a pod is selected by at least one NetworkPolicy, only the allowed traffic is permitted. That’s why a default-deny is the foundation.
A common mental model:
- A policy is a gatekeeper at each pod’s network boundary.
- If the pod isn’t selected by any policy, the gate is wide open.
- If it is selected, only entries in the policy’s allowlist pass.
Namespaces matter. You can restrict rules to the same namespace, or allow cross-namespace traffic. You can also select nothing (empty pod selector) to apply a rule to all pods in a namespace.
Building a zero-trust baseline: default deny
Let’s start with a practical, minimal setup. We’ll create a namespace and enforce a default-deny policy. Nothing gets in or out unless we allow it.
Project structure:
k8s-network-policies/
├── namespaces/
│ └── app-ns.yaml
├── policies/
│ └── default-deny.yaml
└── README.md
Namespace definition (namespaces/app-ns.yaml):
apiVersion: v1
kind: Namespace
metadata:
name: app-ns
labels:
name: app-ns
Default deny policy (policies/default-deny.yaml):
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: app-ns
spec:
podSelector: {} # select all pods in the namespace
policyTypes:
- Ingress
- Egress
ingress: [] # no ingress allowed
egress: [] # no egress allowed
Apply:
kubectl apply -f namespaces/app-ns.yaml
kubectl apply -f policies/default-deny.yaml
With this in place, pods in app-ns can’t talk to anything and nothing can talk to them. That’s a strong starting point. Now we selectively open what we need.
Real-world case: frontend, backend, database
Suppose we have three components in app-ns:
- Frontend: web UI, serves HTTP on 8080.
- Backend: REST API, serves HTTP on 8081, needs to read from the database.
- Database: PostgreSQL on 5432, only accessible to backend.
Our policies should allow:
- Ingress to frontend from anywhere in the cluster (for a LoadBalancer or Ingress controller).
- Ingress to backend from frontend pods only.
- Egress from backend to database pods on port 5432.
- DNS egress for all pods (commonly UDP 53, sometimes TCP 53).
A useful pattern is to label pods for selector clarity:
# Example deployment labels
metadata:
labels:
app: frontend
component: ui
Here’s a minimal set of policies you might use:
# Allow ingress to frontend from anywhere in the cluster (broad for simplicity).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-ingress
namespace: app-ns
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 8080
# No "from" means from all sources; tighten this in production.
# Allow backend to be reached from frontend pods only.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-from-frontend
namespace: app-ns
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8081
# Allow backend egress to database pods on port 5432.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-to-db
namespace: app-ns
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
# Allow DNS egress for all pods (adjust selectors as needed).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: app-ns
spec:
podSelector: {} # all pods
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {} # cluster DNS usually in kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Apply and verify:
kubectl apply -f policies/
kubectl get networkpolicies -n app-ns
Test access from a debug pod:
kubectl run -it --rm debug --image=curlimages/curl --restart=Never -- /bin/sh
# Inside the shell:
curl http://frontend.app-ns.svc.cluster.local:8080
curl http://backend.app-ns.svc.cluster.local:8081
# These should work depending on your Ingress setup and labels.
Fun fact: pod selectors operate on labels, so consistent labeling across deployments is your best friend. Consider a small conventions doc in your repo:
labels/
├── app.yaml # shared app labels
└── component.yaml # component-specific labels
Policy debugging and validation: avoid outages
Network Policies are powerful but easy to lock yourself out with. A few techniques help:
Dry-run and policy diagnostics
Some CNIs provide dry-run or audit modes. For example, Calico can evaluate policies against live traffic without enforcing them. This helps you see what would be blocked before cutting connectivity.
Iterative rollout
- Start with dry-run or per-namespace policies.
- Add
kubectlpre-checks with a validation script (example below). - Deploy off-peak and use a canary namespace.
Simple validation script
This script checks that all policies in a namespace have valid protocol and port fields. It’s not a full linter, but it catches common mistakes.
# scripts/lint-policies.py
import yaml
import sys
from pathlib import Path
def lint_policy_file(path):
with open(path) as f:
docs = list(yaml.safe_load_all(f))
errors = []
for doc in docs:
if doc.get("kind") != "NetworkPolicy":
continue
spec = doc.get("spec", {})
for policy_type in spec.get("policyTypes", []):
rules = spec.get(policy_type.lower(), [])
for rule in rules:
ports = rule.get("ports", [])
for port in ports:
if "port" not in port:
errors.append(f"{path}: {doc['metadata']['name']} missing port")
if port.get("protocol") not in ("TCP", "UDP", "SCTP"):
errors.append(f"{path}: {doc['metadata']['name']} bad protocol")
return errors
if __name__ == "__main__":
paths = sys.argv[1:] or [p for p in Path("policies").glob("*.yaml")]
total_errors = []
for p in paths:
total_errors.extend(lint_policy_file(p))
if total_errors:
print("Policy lint errors:", file=sys.stderr)
for e in total_errors:
print(e, file=sys.stderr)
sys.exit(1)
else:
print("No policy lint errors.")
Run:
python scripts/lint-policies.py policies/*.yaml
Observability
Enable eBPF-based observability if your CNI supports it. Cilium’s Hubble or Calico’s flow logs help you see which flows are denied and why. Attach labels to your pods and policies so you can filter by app, component, or environment.
Strengths, weaknesses, and tradeoffs
Strengths:
- Native and portable: part of the Kubernetes API, works across clusters and vendors.
- Incremental: adopt default-deny first, then layer on rules.
- Fine-grained: selective by pod labels, namespaces, IPs, and ports.
- Low overhead: no sidecars, no extra proxies, just CNI enforcement.
Weaknesses:
- Policy scope is L3/L4 only: IPs and ports, not HTTP paths or identities. For L7, you’ll need a service mesh or API gateway.
- Enforcement depends on your CNI: not all CNIs implement Network Policies, and feature gaps exist (e.g., SCTP support, IPBlock nuances).
- Debugging can be tricky without good observability tooling.
- Label hygiene is essential; messy labels lead to broad or incorrect policies.
When to use:
- For baseline microservice segmentation in any Kubernetes cluster.
- When you want lightweight, declarative controls with minimal operational burden.
- When you need portable policies across clouds and on-prem.
When to consider alternatives:
- If you need per-route rules (HTTP paths, JWT claims), look at service meshes like Istio or Linkerd.
- If you need identity-based policies and deep L7 visibility, a mesh or API gateway is better.
- If your CNI doesn’t support Network Policies, you may need to switch CNIs or rely on external controls (cloud security groups) while you migrate.
Personal experience: lessons from the trenches
The first policies I wrote were too broad. I used empty pod selectors where I should have matched labels, and I didn’t allow DNS egress, which made pods mysteriously fail on service resolution. That one took a while to trace.
I learned to start with a namespace-level default-deny, then add policies in small batches. Observability saved me: seeing denied flows made it obvious what we missed. I also learned to standardize labels early. We started with a simple schema: app, component, and tier. That kept selectors readable and prevented accidental open rules.
Another hard-won insight: Network Policies don’t replace security reviews. They’re part of a layered approach. We still rely on TLS for encryption in transit, RBAC for control plane access, and admission policies to enforce conventions. What Network Policies do is reduce lateral movement dramatically, which is often the biggest risk in a microservices environment.
Getting started: workflow and mental models
You don’t need a complex setup to begin. The mental model is simple:
- Start with a default-deny namespace.
- Map your service dependencies (frontend -> backend -> db).
- Write allow rules for each dependency, using label selectors.
- Validate with scripts, then test in a non-critical namespace.
- Observe flows and adjust.
Suggested folder structure:
cluster/
├── namespaces/
│ ├── app-ns.yaml
│ └── observability-ns.yaml
├── policies/
│ ├── default-deny.yaml
│ ├── allow-dns.yaml
│ ├── app-frontend.yaml
│ ├── app-backend.yaml
│ └── app-db.yaml
├── scripts/
│ ├── lint-policies.py
│ └── verify-access.sh
└── README.md
A simple verify script for access checks:
# scripts/verify-access.sh
#!/usr/bin/env bash
set -euo pipefail
NS=${1:-app-ns}
kubectl run -it --rm verify --image=curlimages/curl --restart=Never -- \
sh -c "curl -s -o /dev/null -w '%{http_code}' http://frontend.$NS.svc.cluster.local:8080"
Run:
chmod +x scripts/verify-access.sh
./scripts/verify-access.sh app-ns
Make label conventions explicit. Keep them in code and reviewed with policies:
labels/
├── README.md
├── conventions.md
└── templates.yaml
In labels/conventions.md:
Standard labels for services:
- app: human-readable app name, e.g., "shop"
- component: functional unit, e.g., "ui", "api", "db"
- tier: network tier, e.g., "public", "internal", "data"
- environment: "dev", "staging", "prod" (optional, prefer namespaces)
Use these consistently in deployments:
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: app-ns
spec:
selector:
matchLabels:
app: shop
component: ui
template:
metadata:
labels:
app: shop
component: ui
spec:
containers:
- name: server
image: nginx:latest
ports:
- containerPort: 8080
Ecosystem strengths and developer experience
Network Policies shine when paired with:
- Strong label conventions and automated linting.
- CI pipelines that run policy checks on pull requests.
- Observability tools that understand flows by labels (e.g., Cilium Hubble, Calico’s flow logs).
- GitOps workflows: apply policies alongside deployments so they’re versioned and reviewed.
Developer experience improves when app teams don’t need to write policies from scratch. Provide templates they can copy and customize. For example, you might keep a “policy recipes” folder with common patterns like:
- Service-to-service allow by app/component.
- Ingress allow from a specific namespace (e.g., ingress-nginx).
- Egress to external services (e.g., object storage APIs via IP blocks).
Platform teams should also consider policy governance:
- Use Kubernetes admission controllers to enforce that namespaces have a default-deny.
- Review changes to policies via PRs just like code.
- Track policy coverage (percentage of namespaces with default-deny and allow rules).
Free learning resources
- Kubernetes Network Policies Documentation - Official API and examples.
- Cilium Network Policies - Great for understanding eBPF-based enforcement and L7 policies.
- Calico Network Policy - Practical guidance and implementation details for policy enforcement.
- Project Calico eBPF and Flow Logs - Observability in practice.
- Istio Authorization Policies - When you need L7 identity-aware controls.
- Kubernetes Security Best Practices - Broader context for cluster hardening.
Summary and who should use Network Policies
Use Kubernetes Network Policies if:
- You run microservices and want to reduce lateral movement.
- You prefer declarative, cluster-native controls that move with your workloads.
- You can adopt label conventions and basic linting in your workflow.
- Your CNI supports enforcement (Calico, Cilium, etc.) or you’re willing to migrate.
Consider skipping or supplementing them if:
- Your CNI doesn’t implement Network Policies and you can’t change it.
- You need L7 identity-aware policies (use a service mesh).
- You have very simple deployments with few network dependencies (policies still help, but prioritize other controls first).
The takeaway: Network Policies are a practical, low-overhead way to implement a default-deny stance and gradually harden your cluster. Start small, validate thoroughly, observe traffic, and evolve policies alongside your architecture. If you keep label hygiene and lean on CI checks, you’ll tighten security without slowing teams down.




