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:
| Object | Scope | What it does |
|---|---|---|
Role | Namespace | Defines permissions within a namespace |
ClusterRole | Cluster-wide | Defines permissions across all namespaces |
RoleBinding | Namespace | Assigns a Role to a user or ServiceAccount |
ClusterRoleBinding | Cluster-wide | Assigns 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:
| Level | What it means |
|---|---|
privileged | No restrictions (cluster admins only) |
baseline | Blocks clearly dangerous settings (e.g., hostNetwork, privileged: true) |
restricted | Strongest 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