Hardening Helm charts with Pod Security Standards, least-privilege RBAC, supply chain verification, and policy enforcement.
Lesson 20 — Helm Charts: From Templates to Production Kubernetes
Kubernetes defines three Pod Security Standards. Target Restricted for all production workloads.
Pod-level applies to all containers: runAsNonRoot, fsGroup, seccompProfile.
Container-level overrides per container: readOnlyRootFilesystem, allowPrivilegeEscalation, capabilities.
# templates/deployment.yaml
spec:
template:
spec:
securityContext: # Pod-level
runAsNonRoot: {{ .Values.podSecurityContext.runAsNonRoot | default true }}
fsGroup: {{ .Values.podSecurityContext.fsGroup | default 1000 }}
seccompProfile:
type: RuntimeDefault
containers:
- name: {{ .Chart.Name }}
securityContext: # Container-level
readOnlyRootFilesystem: {{ .Values.securityContext.readOnlyRootFilesystem | default true }}
allowPrivilegeEscalation: {{ .Values.securityContext.allowPrivilegeEscalation | default false }}
capabilities:
drop: ["ALL"]
Authenticate to private container registries using imagePullSecrets. Two approaches:
spec:
template:
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
# values.yaml
imagePullSecrets:
- name: registry-creds
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "myapp.fullname" . }}
imagePullSecrets:
- name: {{ .Values.registry.secretName }}
Attaching the secret to the ServiceAccount avoids repeating it on every pod spec.
kubectl create secret docker-registry registry-creds --docker-server=... --docker-username=... --docker-password=...Every application should run under its own ServiceAccount with only the permissions it needs — never use the default ServiceAccount.
# templates/role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "myapp.fullname" . }}
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "watch"]
resourceNames: ["{{ include "myapp.fullname" . }}-config"] # scope to specific resources
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
resourceNames: ["{{ include "myapp.fullname" . }}-secret"]
# templates/rolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "myapp.fullname" . }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "myapp.fullname" . }}
subjects:
- kind: ServiceAccount
name: {{ include "myapp.fullname" . }}
namespace: {{ .Release.Namespace }}
resources: ["*"], verbs: ["*"]). Always enumerate exact resources and verbs. Use resourceNames to restrict to specific objects when possible.Protect your deployment pipeline from tampered charts and images.
helm package --sign --key mykey ./mychart creates a .prov provenance filehelm install --verify myrelease ./mychart-1.0.0.tgzimage@sha256:... instead of mutable tags:latest — mutable tags break reproducibility# values.yaml - pin by digest
image:
repository: ghcr.io/myorg/myapp
digest: "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
tag: "" # ignored when digest is set
# templates/deployment.yaml
image: "{{ .Values.image.repository }}{{- if .Values.image.digest }}@{{ .Values.image.digest }}{{- else }}:{{ .Values.image.tag }}{{- end }}"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
Add a LimitRange as a safety net for pods that forget:
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
spec:
limits:
- default:
cpu: "500m"
memory: "256Mi"
defaultRequest:
cpu: "100m"
memory: "128Mi"
type: Container
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "myapp.fullname" . }}
spec:
podSelector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
policyTypes: ["Ingress", "Egress"]
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: database
ports:
- port: 5432
- to: # Allow DNS
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
values.yaml to Git--set flags or external secret operators (External Secrets Operator, Sealed Secrets, Vault)# Install with runtime secrets
helm install myapp ./mychart \
--set secrets.dbPassword="$DB_PASS" \
--set secrets.apiKey="$API_KEY"
# Audit with kube-score
helm template myapp ./mychart | \
kube-score score -
# Audit with Polaris
polaris audit --helm-chart ./mychart
# Gatekeeper ConstraintTemplate
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
Review every chart against these criteria before deploying to production:
runAsNonRoot: true and readOnlyRootFilesystem: true on all containersallowPrivilegeEscalation: false and capabilities.drop: ["ALL"]requests and limits set on every container:latest tags — use specific versions or digestsvalues.yaml or templatesdefault)automountServiceAccountToken: false if the app does not call the Kubernetes API:latest tag, hostNetwork: true, and storing secrets in version control.