A Kubernetes cluster has many attack surfaces. Misconfigured Pods can break out of their namespace. Overprivileged service accounts can access the entire cluster. Unscanned images can run with known vulnerabilities.

This tutorial covers the essential security practices before taking any Kubernetes application to production.

The 4Cs of Cloud Native Security

Think of Kubernetes security in layers:

Cloud → Cluster → Container → Code
  • Code — vulnerabilities in your application code
  • Container — image security, running as non-root, minimal base images
  • Cluster — RBAC, Pod Security Standards, Network Policies
  • Cloud — network firewall rules, IAM policies, cloud provider security

Each layer depends on the one below it. Fixing only one layer is not enough. This tutorial focuses on the Container and Cluster layers.

RBAC — Principle of Least Privilege

RBAC (Role-Based Access Control) controls who can do what in your cluster.

The key objects:

ObjectScopeWhat it does
RoleNamespaceDefines permissions within a namespace
ClusterRoleCluster-wideDefines permissions across all namespaces
RoleBindingNamespaceAssigns a Role to a user or ServiceAccount
ClusterRoleBindingCluster-wideAssigns a ClusterRole cluster-wide

Creating a Role with Minimal Permissions

This Role lets a CI deployment service account update Deployments in one namespace — nothing else.

# ci-role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci-deployer
  namespace: production
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "update", "patch"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]

Bind it to a ServiceAccount:

# ci-serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci-deployer
  namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-deployer-binding
  namespace: production
subjects:
  - kind: ServiceAccount
    name: ci-deployer
    namespace: production
roleRef:
  kind: Role
  name: ci-deployer
  apiGroup: rbac.authorization.k8s.io
kubectl apply -f ci-role.yaml -f ci-serviceaccount.yaml

Disable Automatic ServiceAccount Token Mount

By default, every Pod gets a ServiceAccount token mounted automatically. Most Pods do not need API access. Disable it:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      automountServiceAccountToken: false   # Disable for Pods that don't need API access
      containers:
        - name: myapp
          image: myapp:1.0

Pod Security Standards

Pod Security Standards (PSS) define security profiles that are enforced at the namespace level. They replaced the deprecated PodSecurityPolicy in K8s 1.25.

Three levels:

LevelWhat it means
privilegedNo restrictions (cluster admins only)
baselineBlocks clearly dangerous settings (e.g., hostNetwork, privileged: true)
restrictedStrongest hardening — runs as non-root, drops all capabilities

Apply a security standard to a namespace with a label:

# Enforce restricted security for the production namespace
kubectl label namespace production \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

Any Pod that violates the restricted standard will be rejected. Check what you need to fix first:

# Audit mode — warns without blocking
kubectl label namespace production pod-security.kubernetes.io/audit=restricted

# Then check audit logs for violations
kubectl get events -n production

A Pod Compliant with restricted

apiVersion: v1
kind: Pod
metadata:
  name: secure-pod
  namespace: production
spec:
  securityContext:
    runAsNonRoot: true         # Never run as root
    runAsUser: 1000
    seccompProfile:
      type: RuntimeDefault     # Use container runtime's default seccomp profile
  containers:
    - name: myapp
      image: myapp:1.0
      securityContext:
        allowPrivilegeEscalation: false  # Cannot gain more privileges than parent
        readOnlyRootFilesystem: true     # Filesystem is read-only
        capabilities:
          drop:
            - ALL                        # Drop all Linux capabilities

Network Policies — Default Deny

By default, every Pod in a Kubernetes cluster can talk to every other Pod. This is a problem if one service is compromised.

Network Policies let you define which Pods can communicate.

Start with a default-deny rule for a namespace:

# default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}          # Applies to all Pods
  policyTypes:
    - Ingress
    - Egress

Now no Pod can send or receive traffic. Important: This also blocks DNS (UDP port 53 to kube-dns). Add an explicit egress rule for DNS before applying to production. Then add your allow rules:

# allow-web-to-api.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-web-to-api
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api-service        # This policy applies to api-service Pods
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: web-service  # Only web-service Pods can reach api-service
      ports:
        - port: 8080
kubectl apply -f default-deny.yaml -f allow-web-to-api.yaml

Image Security

Kubernetes runs whatever image you tell it to. If the image has vulnerabilities, your cluster is at risk.

Scan Images Before Deploying

Use Trivy to scan images for known CVEs:

# Install Trivy
brew install trivy   # macOS

# Scan an image
trivy image myapp:1.0

# Scan and fail if HIGH or CRITICAL vulnerabilities found (good for CI)
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:1.0

Add this to your CI pipeline before pushing to the registry.

Use Minimal Base Images

A smaller image has fewer packages and fewer attack vectors. See Docker Tutorial #7: Multi-stage Builds for how to build minimal images.

# Build stage
FROM golang:1.24 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .

# Runtime stage — distroless has no shell, no package manager
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
CMD ["/server"]

Image Signing with Cosign

Sign your images so Kubernetes can verify they came from your CI pipeline:

# Install cosign
brew install cosign

# Sign an image
cosign sign ghcr.io/kemalcodes/myapp:abc1234

# Verify
cosign verify ghcr.io/kemalcodes/myapp:abc1234

Secrets Management in Production

As covered in Kubernetes Tutorial #4, Kubernetes Secrets are only base64-encoded. For production, use the External Secrets Operator to sync secrets from a real secrets manager.

# Pull secrets from AWS Secrets Manager
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-password
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-store
    kind: SecretStore
  target:
    name: db-password
  data:
    - secretKey: password
      remoteRef:
        key: myapp/prod
        property: db_password

Audit Logging

Kubernetes can log every API call — who did what and when. But audit logging is disabled by default. An admin must enable it by passing --audit-policy-file and --audit-log-path flags to the kube-apiserver.

Check if audit logging is configured (minikube — adapt the pod name for your cluster):

kubectl -n kube-system get pod kube-apiserver-minikube -o yaml | grep audit

An audit log entry looks like:

{
  "kind": "Event",
  "apiVersion": "audit.k8s.io/v1",
  "verb": "delete",
  "user": {"username": "system:serviceaccount:default:ci-deployer"},
  "objectRef": {"resource": "pods", "name": "myapp-abc123", "namespace": "production"}
}

Unusual events to watch for: delete on secrets, create on privileged Pods, exec into Pods in production.

Using Namespaces for Isolation

Never run everything in the default namespace in production. Use separate namespaces for different environments and teams:

kubectl create namespace production
kubectl create namespace staging
kubectl create namespace monitoring

Apply Pod Security Standards and Network Policies per namespace. Limit resource usage with a ResourceQuota:

# resource-quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: production-quota
  namespace: production
spec:
  hard:
    pods: "50"
    requests.cpu: "10"
    requests.memory: 20Gi
    limits.cpu: "20"
    limits.memory: 40Gi

CIS Kubernetes Benchmark

The CIS Kubernetes Benchmark is an industry-standard checklist of Kubernetes security settings. Use kube-bench to automatically check your cluster against it:

# Run kube-bench on minikube
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml

kubectl logs -f job/kube-bench

The output shows PASS, FAIL, and WARN for each CIS check with remediation instructions.

Common Mistakes

Running everything in the default namespace

No isolation. One compromised pod can reach all other pods. Use separate namespaces per team, environment, and service.

automountServiceAccountToken: true on all Pods

This is the default. Most Pods do not need Kubernetes API access. A compromised Pod with a mounted service account token can query (or modify) the cluster API. Always set automountServiceAccountToken: false unless the Pod specifically needs API access.

No Network Policies

By default, all Pods can talk to all Pods. A compromised frontend should not be able to directly reach your database. Set up Network Policies before going to production.

What’s Next?

Your cluster is secure. The last operational topic is scaling — making sure your app handles load efficiently.

Next: Kubernetes Tutorial #11: Scaling in Kubernetes — HPA, VPA, and KEDA