Lab Guide: Security & Best Practices
Three exercises: harden a chart with securityContext and resource limits, add least-privilege RBAC, and audit templates with kube-score and Polaris.
Prerequisites
A running Kubernetes cluster (minikube or kind). kubectl and helm installed. For Exercise 3, install kube-score and Polaris CLI (or use the container images).
Exercise 1: Harden a Chart with securityContext & Resource Limits
Start with a default Helm chart and add production-grade security context settings and resource constraints that comply with the Restricted Pod Security Standard.
Step 1 — Scaffold a chart
helm create secure-app
cd secure-app
Step 2 — Add pod-level securityContext
Edit templates/deployment.yaml. Under spec.template.spec, add a pod-level security context that drops all capabilities and enforces a read-only root filesystem. Use values so operators can override if needed:
securityContext:
runAsNonRoot: {{ .Values.podSecurityContext.runAsNonRoot }}
runAsUser: {{ .Values.podSecurityContext.runAsUser }}
runAsGroup: {{ .Values.podSecurityContext.runAsGroup }}
fsGroup: {{ .Values.podSecurityContext.fsGroup }}
seccompProfile:
type: {{ .Values.podSecurityContext.seccompProfile.type }}
Step 3 — Add container-level securityContext
Under each container in the Deployment spec, add:
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: {{ .Values.containerSecurityContext.readOnlyRootFilesystem }}
capabilities:
drop:
- ALL
Step 4 — Define the values defaults
In values.yaml, add sensible defaults:
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containerSecurityContext:
readOnlyRootFilesystem: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
Step 5 — Wire up resource limits
Ensure the container template in templates/deployment.yaml references the resources block (the default helm create chart may already have this, but verify):
resources:
{{- toYaml .Values.resources | nindent 12 }}
Step 6 — Render and validate
# Render locally
helm template secure-app . | grep -A 20 securityContext
# Install into a namespace with restricted PSS enforcement
kubectl create namespace secure-test
kubectl label namespace secure-test \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted
helm install secure-app . -n secure-test
# Verify the pod is running (not rejected by admission)
kubectl get pods -n secure-test
Success Criteria
helm templaterenders with all securityContext fields populated- Pod runs in a
restrictedPSS namespace without admission errors - Both
resources.requestsandresources.limitsappear in the rendered Deployment allowPrivilegeEscalation: falseandcapabilities.drop: [ALL]are set
Exercise 2: Least-Privilege RBAC with a Dedicated ServiceAccount
Create a chart-managed ServiceAccount with a Role scoped to only the resources the application actually needs. Avoid ClusterRole unless absolutely required.
Step 1 — Create a ServiceAccount template
Create templates/serviceaccount.yaml:
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "secure-app.fullname" . }}
labels:
{{- include "secure-app.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automountToken }}
{{- end }}
Step 2 — Create a Role with minimal permissions
Create templates/role.yaml. Grant only read access to ConfigMaps in the release namespace:
{{- if .Values.rbac.create }}
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "secure-app.fullname" . }}
labels:
{{- include "secure-app.labels" . | nindent 4 }}
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
{{- end }}
Step 3 — Bind the Role to the ServiceAccount
Create templates/rolebinding.yaml:
{{- if and .Values.rbac.create .Values.serviceAccount.create }}
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "secure-app.fullname" . }}
labels:
{{- include "secure-app.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "secure-app.fullname" . }}
subjects:
- kind: ServiceAccount
name: {{ include "secure-app.fullname" . }}
namespace: {{ .Release.Namespace }}
{{- end }}
Step 4 — Add values for RBAC control
In values.yaml:
serviceAccount:
create: true
annotations: {}
automountToken: false # Don't mount token unless the app needs K8s API access
rbac:
create: true
Why automountToken: false?
Most application pods never need to talk to the Kubernetes API. Mounting a service account token gives any container in the pod free access to the API server. Set it to false by default and only enable it for pods that genuinely need in-cluster API access.
Step 5 — Reference the ServiceAccount in the Deployment
In templates/deployment.yaml, under spec.template.spec:
serviceAccountName: {{ include "secure-app.fullname" . }}
automountServiceAccountToken: {{ .Values.serviceAccount.automountToken }}
Step 6 — Deploy and verify
# Upgrade the release
helm upgrade secure-app . -n secure-test
# Verify ServiceAccount exists
kubectl get sa -n secure-test
# Verify Role permissions are minimal
kubectl describe role -n secure-test
# Verify no token is mounted in the pod
kubectl get pod -n secure-test -o jsonpath='{.items[0].spec.automountServiceAccountToken}'
# Should print: false
Success Criteria
- A dedicated ServiceAccount is created (not using
default) - Role grants only
get,list,watchonconfigmaps(no wildcards) automountServiceAccountTokenisfalseon both the ServiceAccount and Deployment- No
ClusterRoleorClusterRoleBindingis created
Exercise 3: Audit with kube-score and Polaris
Run automated security scanners against your rendered templates, interpret the results, and fix every issue they report.
Step 1 — Install the tools
# kube-score (macOS)
brew install kube-score
# Polaris CLI (macOS)
brew install FairwindsOps/tap/polaris
# Or use container images:
# docker run -v $(pwd):/chart zegl/kube-score:latest score /chart/rendered.yaml
# docker run -v $(pwd):/chart quay.io/fairwinds/polaris:latest audit --audit-path /chart/rendered.yaml
Step 2 — Render templates to a file
helm template secure-app . -n secure-test > rendered.yaml
Step 3 — Run kube-score
kube-score score rendered.yaml
kube-score checks for common issues including:
- Missing resource requests/limits
- Missing readiness/liveness probes
- Running as root
- Missing network policies
- Image tag pinning (using
:latestis flagged)
Step 4 — Run Polaris
polaris audit --audit-path rendered.yaml --format pretty
Polaris scores your manifest on a 0-100 scale and categorises findings as danger, warning, or passing. Review each failing check.
Step 5 — Fix all reported issues
Common fixes you may need to apply:
| Finding | Fix |
|---|---|
| Missing liveness probe | Add livenessProbe with httpGet or exec check |
| Missing readiness probe | Add readinessProbe with appropriate endpoint |
Image tag is latest | Pin to a specific tag or SHA digest in values.yaml |
| No NetworkPolicy | Add a default-deny NetworkPolicy template (see below) |
| CPU limit not set | Ensure resources.limits.cpu is defined |
Step 6 — Add a default-deny NetworkPolicy
Create templates/networkpolicy.yaml:
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "secure-app.fullname" . }}
labels:
{{- include "secure-app.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "secure-app.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: {{ include "secure-app.name" . }}-client
ports:
- protocol: TCP
port: {{ .Values.service.port }}
egress:
- to: [] # DNS resolution
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- end }}
Add to values.yaml:
networkPolicy:
enabled: true
Step 7 — Re-render and re-scan
# Re-render after fixes
helm template secure-app . -n secure-test > rendered.yaml
# Run both tools again
kube-score score rendered.yaml
polaris audit --audit-path rendered.yaml --format pretty
# Aim for zero critical/danger findings and a Polaris score above 90
Success Criteria
- kube-score reports zero CRITICAL findings
- Polaris audit score is 90 or above with no danger items
- A default-deny NetworkPolicy is included in the rendered output
- All container images use a pinned tag (not
:latest) - Liveness and readiness probes are defined
Cleanup
helm uninstall secure-app -n secure-test
kubectl delete namespace secure-test
rm rendered.yaml