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:

NamespaceServiceConsumers
postgresPostgreSQLwikijs, vikunja, kanban
minioMinIO S3Backup jobs, Loki, gitlab-runner
monitoringPrometheus/Grafana/LokiAll 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:

  1. Prometheus needs to scrape metrics from all namespaces
  2. Backup jobs need to reach external cloud storage (Scaleway)
  3. 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:

  1. Separate files are easier to audit and review
  2. Cross-namespace dependencies are clearer in dedicated manifests
  3. 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

NamespacePoliciesAllowed Sources
postgres4wikijs, vikunja, kanban, monitoring
minio3postgres, immudb, kube-system, monitoring, gitlab-runner, kanban
monitoring6Any (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.