Linkerd provides automatic mTLS between all pods in the mesh. This encrypts traffic and provides identity verification. However, it does not restrict which pods can communicate with each other. Any pod in the mesh can connect to any other pod.
Kubernetes NetworkPolicies add an additional layer of security by defining explicit allow rules at the network level. This provides defense-in-depth: if Linkerd’s proxy is somehow bypassed, NetworkPolicies still enforce access control.
The Problem
The cluster runs several stateful services:
| Namespace | Service | Consumers |
|---|---|---|
| postgres | PostgreSQL | wikijs, vikunja, kanban |
| minio | MinIO S3 | Backup jobs, Loki, gitlab-runner |
| monitoring | Prometheus/Grafana/Loki | All namespaces |
Without NetworkPolicies, any pod in any namespace can connect to these services. While Linkerd mTLS ensures the connection is encrypted and authenticated, it does not prevent unauthorized access from within the mesh.
Solution Architecture
┌────────────────────────────────────────────────────────────────────┐
│ Default Deny │
│ All ingress blocked unless explicitly allowed │
└────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ postgres │ │ minio │ │ monitoring │
├───────────────┤ ├───────────────┤ ├───────────────┤
│ Allow from: │ │ Allow from: │ │ Allow from: │
│ - wikijs │ │ - postgres │ │ - Any (9090) │
│ - vikunja │ │ - immudb │ │ - Any (3000) │
│ - kanban │ │ - kube-system │ │ - Any (3100) │
│ - monitoring │ │ - monitoring │ │ - Internal │
│ (metrics) │ │ - gitlab-runner│ │ │
└───────────────┘ │ - kanban │ └───────────────┘
│ - Any (9001) │
└───────────────┘
Each namespace gets a default-deny policy that blocks all ingress traffic. Additional policies then allow specific traffic flows.
Implementation
Directory Structure
infrastructure/postgres/networkpolicy.yaml
infrastructure/minio/networkpolicy.yaml
monitoring/networkpolicy.yaml
PostgreSQL NetworkPolicy
The PostgreSQL namespace hosts the shared database used by multiple applications. Access is restricted to known consumers:
---
# Default deny all ingress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgres-default-deny
namespace: postgres
spec:
podSelector: {}
policyTypes:
- Ingress
---
# Allow PostgreSQL access from application namespaces
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-postgres-clients
namespace: postgres
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: wikijs
ports:
- protocol: TCP
port: 5432
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: vikunja
ports:
- protocol: TCP
port: 5432
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kanban
ports:
- protocol: TCP
port: 5432
- from:
- podSelector: {}
ports:
- protocol: TCP
port: 5432
---
# Allow Prometheus metrics scraping
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-scrape
namespace: postgres
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: 9187
---
# Allow pgAdmin web access from LoadBalancer
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-pgadmin-ingress
namespace: postgres
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: pgadmin
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 80
The kubernetes.io/metadata.name label is automatically applied to all namespaces by Kubernetes, making it reliable for namespace selection.
MinIO NetworkPolicy
MinIO provides S3-compatible object storage for backups, Loki logs, and application data:
---
# Default deny all ingress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: minio-default-deny
namespace: minio
spec:
podSelector: {}
policyTypes:
- Ingress
---
# Allow MinIO S3 API access from authorized namespaces
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-minio-s3-clients
namespace: minio
spec:
podSelector:
matchLabels:
app: minio
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: postgres
ports:
- protocol: TCP
port: 9000
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: immudb
ports:
- protocol: TCP
port: 9000
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: 9000
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: 9000
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: gitlab-runner
ports:
- protocol: TCP
port: 9000
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kanban
ports:
- protocol: TCP
port: 9000
- from:
- podSelector: {}
ports:
- protocol: TCP
port: 9000
---
# Allow MinIO Console access from LoadBalancer
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-minio-console
namespace: minio
spec:
podSelector:
matchLabels:
app: minio
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 9001
The etcd backup CronJob runs in kube-system, so that namespace needs access to upload backups.
Monitoring NetworkPolicy
The monitoring namespace needs broader access since Prometheus, Grafana, and Loki serve the entire cluster:
---
# Default deny all ingress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: monitoring-default-deny
namespace: monitoring
spec:
podSelector: {}
policyTypes:
- Ingress
---
# Allow Prometheus access from any namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-access
namespace: monitoring
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 9090
---
# Allow Grafana access from any namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-grafana-access
namespace: monitoring
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: grafana
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 3000
---
# Allow Loki access from any namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-loki-access
namespace: monitoring
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: loki
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 3100
---
# Allow Alertmanager access from same namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-alertmanager-access
namespace: monitoring
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: alertmanager
policyTypes:
- Ingress
ingress:
- from:
- podSelector: {}
ports:
- protocol: TCP
port: 9093
---
# Allow internal monitoring communication
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-monitoring-internal
namespace: monitoring
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector: {}
Prometheus, Grafana, and Loki are intentionally open to all namespaces. Alertmanager is restricted to internal access only.
Applying the Policies
kubectl apply -f infrastructure/postgres/networkpolicy.yaml
kubectl apply -f infrastructure/minio/networkpolicy.yaml
kubectl apply -f monitoring/networkpolicy.yaml
Verification
Check that policies are applied:
kubectl get networkpolicies -A
Test that unauthorized access is blocked:
kubectl run netpol-test --rm -it --restart=Never --image=busybox -n default \
-- nc -zv -w 3 postgresql.postgres.svc.cluster.local 5432
This command should fail with a timeout since the default namespace is not in the allow list.
Verify services are still accessible via LoadBalancer:
curl -s -o /dev/null -w "%{http_code}" http://192.168.2.201 # Grafana
curl -s -o /dev/null -w "%{http_code}" http://192.168.2.202 # Prometheus
Rollback
If connectivity breaks unexpectedly:
kubectl delete networkpolicy -n postgres --all
kubectl delete networkpolicy -n minio --all
kubectl delete networkpolicy -n monitoring --all
Design Decisions
Why Default-Deny Ingress Only
The policies only restrict ingress (incoming) traffic. Egress (outgoing) traffic is unrestricted. This is intentional:
- Prometheus needs to scrape metrics from all namespaces
- Backup jobs need to reach external cloud storage (Scaleway)
- Egress filtering is more complex and requires careful DNS handling
Why Not Use Helm Chart NetworkPolicies
Many Helm charts include built-in NetworkPolicy options. These were disabled in favor of separate policy files:
- Separate files are easier to audit and review
- Cross-namespace dependencies are clearer in dedicated manifests
- Policies can be updated without redeploying the application
Label Selection
The policies use kubernetes.io/metadata.name for namespace selection. This label is automatically applied by Kubernetes to every namespace and matches the namespace name exactly. This is more reliable than custom labels that could be forgotten or misapplied.
Summary
| Namespace | Policies | Allowed Sources |
|---|---|---|
| postgres | 4 | wikijs, vikunja, kanban, monitoring |
| minio | 3 | postgres, immudb, kube-system, monitoring, gitlab-runner, kanban |
| monitoring | 6 | Any (for Prometheus/Grafana/Loki), internal only (for Alertmanager) |
NetworkPolicies now provide an additional security layer beyond Linkerd mTLS. Unauthorized pods cannot connect to critical services even if they are part of the service mesh.
Configuration available at k8s-configs/infrastructure/postgres/networkpolicy.yaml.