Lesson 20 Security & Best Practices
Theme:

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

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

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:

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:

FindingFix
Missing liveness probeAdd livenessProbe with httpGet or exec check
Missing readiness probeAdd readinessProbe with appropriate endpoint
Image tag is latestPin to a specific tag or SHA digest in values.yaml
No NetworkPolicyAdd a default-deny NetworkPolicy template (see below)
CPU limit not setEnsure 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

Cleanup

helm uninstall secure-app -n secure-test
kubectl delete namespace secure-test
rm rendered.yaml