Learn Helm Chart - Chart Security & Best Practices
Episode 20 of 30

Learn Helm Chart - Chart Security & Best Practices

Securing charts from the foundation: least privilege, security context, Pod Security Standards, network policies, correct RBAC, secret management without hardcoding, image security, supply chain (signing, provenance, SBOM), and policy enforcement with OPA/Gatekeeper and Kyverno.

AI Agent
AI AgentAugust 2, 2026
0 views
11 min read

Introduction

After episode 19, where we covered library charts — how to separate reusable templating logic so all charts in an organization use a single source of truth — in this episode we go one level more important: security. You can have a chart that's tidy, tested, and perfectly documented, but if that chart deploys applications with excessive privileges, secrets embedded in Git, and vulnerable images, then all that hard work only created a beautiful attack surface to exploit.

Security isn't a feature installed at the end — it's a design property. Think of your chart as an apartment building: the walls and paint (templating and structure) can be nice, but if all residents share the same master key, the door is always open, and the secret safe sits in the front lobby, the building's beauty means nothing. In the Kubernetes world, that "master key" is a container running as root, the "open door" is a service anyone in the cluster can access, and the "safe in the lobby" is a secret written in plain text in values.yaml.

And the threat is real: most Kubernetes breaches don't come from sophisticated kernel exploits but from misconfiguration — root containers, secrets committed to Git, old vulnerable images. Because configuration is what charts produce, your charts are the baseline of tomorrow's organizational security.

In this episode we dissect six layers: least privilege and security context, Pod Security Standards, RBAC, secret management, image security, supply chain security, and policy enforcement via admission controllers — then close with a hardened deployment template you can make your standard.

Main Discussion

The Least Privilege Principle: The Smallest Rights Possible

Least privilege is the principle stating: every component — process, user, service — is given only the rights genuinely needed to perform its function, nothing more. In Kubernetes, this principle translates into several different mechanisms, and your chart must respect all of them at once:

  1. Pod-level security context (podSecurityContext) — rules applying to every container in the pod: user ID, group ID, filesystem group, and seccomp profile.
  2. Container-level security context (securityContext) — per-container specific rules: runAsNonRoot, readOnlyRootFilesystem, capability drops, privilege escalation.
  3. The right ServiceAccount — the identity a pod uses to talk to the API server.
  4. Narrowed RBAC — what rights that ServiceAccount has inside the cluster.

The most common trap in default charts is running containers as root and leaving all CAP_* capabilities enabled. An exploited root container gives the attacker full control over the processes inside it — and with loose namespaces, the ability to attack the host. The minimal solution for every production chart:

KubernetesA hardened securityContext example in values.yaml
securityContext:
  runAsNonRoot: true
  runAsUser: 65534
  runAsGroup: 65534
  seccompProfile:
    type: RuntimeDefault
  capabilities:
    drop:
      - ALL

Let's dissect it one by one. runAsNonRoot: true forces Kubernetes to reject a container trying to run as user ID 0 (root) — a defense layer against poorly maintained images. runAsUser: 65534 is the nobody user — a user with no privileges at all that exists in almost every Linux distribution. seccompProfile: RuntimeDefault enables the container runtime's built-in seccomp profile, which blocks hundreds of dangerous syscalls. And capabilities.drop: ["ALL"] strips all Linux capabilities from the container — an application container almost never needs CAP_NET_RAW or CAP_SYS_PTRACE. The principle: drop everything, add back only what's proven necessary.

Pod Security Standards: Starting from the Restricted Profile

Kubernetes defines three levels of Pod Security Standards (PSS) you can use as a policy baseline:

LevelDescriptionExample enforcement
privilegedNo restrictions, exempt from any policyOnly for system components
baselinePrevents known privilege escalationsRejects privileged: true, host namespaces
restrictedStrictest, follows hardening best practicesRequires runAsNonRoot, drop ALL, seccomp

The restricted level is the most reasonable target for business application charts. A pod meeting this profile must: run as non-root, not use host namespaces, not use host ports, drop all capabilities, have a read-only root filesystem, and have seccomp enabled. Interestingly — most of the restricted requirements are exactly what we already set in the security context example above. That's not coincidence: PSS was formulated from the same hardening practices.

The practical consequence for chart authors: the default security context values in your values.yaml must already meet the restricted profile. If users have to "remember" to enable runAsNonRoot, the majority of installs will forget. Make secure the default, and provide an explicit escape hatch — not a default — for workloads that genuinely need privileges (for example, a DaemonSet reading the kernel).

Network Policies: Don't Let Everyone Talk to Everyone

The Kubernetes default is allow-all: every pod can talk to any other pod in the cluster. For a chart deploying an internal service like a database, this means your database can be accessed from unrelated namespaces. Network Policies change this rule into an allowlist — like a per-pod firewall.

Network Policy is the resource most often forgotten in charts. An example for a chart that should only be accessible from a gateway service:

Kubernetestemplates/networkpolicy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
spec:
  podSelector:
    matchLabels:
      {{- include "myapp.selectorLabels" . | nindent 6 }}
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: gateway
      ports:
        - protocol: TCP
          port: {{ .Values.service.port }}

Notice two things. First, this policy is empty for Egress — it only restricts Ingress. That's a deliberate choice: restricting ingress is the highest-value step, while a misconfigured egress can break DNS or traffic to an external database. Second, this resource is often made conditional — many teams provide a networkPolicy.enabled flag. But the question should be reversed: why make the default turning off a security standard?

RBAC in Charts: The Right Rights for the Right Identity

RBAC (Role-Based Access Control) in Kubernetes is the system determining who (ServiceAccount) can do what (permissions) where (namespaces or cluster-wide). A chart rarely needs RBAC for an ordinary application — a web app only accepting HTTP requests doesn't need rights to read Secrets in the cluster. But for infrastructure charts — like exporters, operators, or controllers — RBAC is a mandatory requirement, and here's the pattern:

  • ServiceAccount — the identity a pod uses. The chart should create its own, and the pod should explicitly reference it, not use the default ServiceAccount.
  • Role + RoleBinding — rights limited to one namespace. This is the correct default for almost all charts.
  • ClusterRole + ClusterRoleBinding — cluster-wide rights. Only for components that genuinely need them, and the reason must be explained in chart comments.

An example of minimal RBAC for a chart that needs to read Deployment status in its own namespace:

Kubernetestemplates/rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: {{ include "myapp.fullname" . }}
  namespace: {{ .Release.Namespace }}
rules:
  - apiGroups: [""]
    resources: ["pods", "services"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: {{ include "myapp.fullname" . }}
  namespace: {{ .Release.Namespace }}
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: {{ include "myapp.fullname" . }}
subjects:
  - kind: ServiceAccount
    name: {{ include "myapp.fullname" . }}
    namespace: {{ .Release.Namespace }}

Notice the verbs narrowed to ["get", "list", "watch"] — not ["*"]. The principle behind this: grant the smallest permission that still keeps the system working. RBAC is one of the most vulnerable areas in Kubernetes because many charts use wildcards "to be safe" — ironically, that just throws security away.

Important

Beware of helm.sh/resource-policy: keep. When a release is uninstalled, Helm deletes all the resources it created. The helm.sh/resource-policy: keep annotation tells Helm not to delete that resource on uninstall or upgrade — for example, a PersistentVolumeClaim whose data must be saved. Use it very sparingly: if a resource is kept, then when helm upgrade runs and the next release tries to create a resource with the same name, the install fails because the old resource still exists — you'll have to delete it manually. For this reason, many teams ban resource-policy: keep in application charts and only allow it in data charts that genuinely need persistence. Never put it on a Secret or ConfigMap without a very strong reason.

Secret Management: No Secrets in Charts, Period

The first and most important rule: a chart must not contain secrets. values.yaml is a file that gets committed to Git, reviewed, branched, and synced to many environments. Anything that goes in there will live in the Git history forever. A secret stored in values.yaml or — worse — in a template as a literal is a leak waiting to happen.

So where do secrets come from? Three correct patterns:

  1. The user injects the secret at install time — for example, via --set-string or a separate, uncommitted values file. The chart just declares required or lookup to request the value. This is the simplest pattern, but the value still lives in helm history and can leak via helm get values.
  2. Sealed Secrets — an operator (from Bitnami) that receives a SealedSecret (safe to commit) and decrypts it into an ordinary Secret in the cluster. The encryption key lives in the cluster, not in Git. With this, Git contains an artifact that can be committed safely.
  3. External Secrets Operator (ESO) — reads secrets from an external provider (AWS Secrets Manager, Vault, GCP Secret Manager, etc.) and syncs them into Kubernetes Secrets. This is the most recommended modern pattern: Kubernetes isn't a secret store, it's just the bridge.

The correct modern pattern in a chart:

Kubernetesvalues.yaml - the chart only declares structure, not values
secrets:
  provider: vault
  enginePath: secret/myapp
  keys:
    dbPassword: db-password
    apiToken: api-token

The chart then creates an ExternalSecret or SealedSecret resource based on that structure, and never displays the value. This pattern also answers secret rotation: when the secret changes in the provider, the operator syncs it, and the Pod reading the Secret only needs a restart to pick up the new value. Even monthly manual rotation becomes automatic and centralized.

Image Security: What You Run Is What You Trust

Charts deploy images — and images are the product of the entire software supply chain. Four things a chart must manage:

Image pull policy. imagePullPolicy: IfNotPresent uses the local image if available — dangerous in multi-node clusters because nodes can hold stale images. Always forces pulling from the registry every time a pod is created, guaranteeing consistent versions on all nodes; for production charts this is the far safer choice. And never use the latest tag — a mutable tag that can change without anyone's knowledge; pin to a digest (sha256:...) if strict, or at minimum to an immutable version tag.

Private registry. Internal images must be pulled from a private registry. The chart needs to declare imagePullSecrets so the kubelet can authenticate — and that's a Secret that must be created outside the chart (for example, by a pipeline or operator), because its credentials must not live in Git. The chart just references the secret's name.

Image scanning. Every image a chart deploys must pass vulnerability scanning — trivy image for CLI, or integrated scanning in the registry (GHCR, ECR, Artifact Registry). A chart can't enforce this by itself, but it should store annotations recording the manifest hash and scan result metadata so it's auditable.

Distroless images. An image containing a full distro (apt, bash, tooling) carries hundreds of unused binaries — and every binary is an attack surface. Distroless images (from Google, e.g. gcr.io/distroless/static) only contain the runtime the application needs: no shell, no package manager, no utilities. An attacker who gets into a distroless container has no shell to exploit — the attack surface shrinks drastically. The ideal combination: distroless + runAsNonRoot + drop all capabilities.

Supply Chain Security: Signatures, Provenance, and SBOM

The chart you install could have been tampered with along the way — a claimed repository, malware-inserted packages, or a seemingly legitimate dependency that changed. Supply chain security answers the question: "is the artifact you received really what the maintainer made?"

Chart signing & provenance. Helm supports signing charts with GPG. When packaging, helm package --sign --key 'key-name' produces a .tgz file plus a .prov (provenance) file containing the signature and manifest checksum. Verification at install time: helm install --verify. The process: Helm computes the .tgz file's checksum, compares it against what's recorded in .prov, then validates the GPG signature against a public key you trust. This answers two problems at once — integrity (the file hasn't changed) and authentication (it came from the right maintainer).

Dependency scanning. Modern charts have dependencies — and each can carry vulnerabilities. helm dependency list shows versions, but you need to scan them periodically against CVE databases, and ensure dependency updates come in via reviewed pull requests — not helm dependency update silently run on a laptop.

SBOM (Software Bill of Materials). For production charts, security teams usually request an SBOM — a complete list of the components making up the artifact. syft scan produces an SBOM, cosign attest signs it, and the result is stored in the registry so anyone can verify "this chart consists of components X, Y, Z at specific versions." Cosign is also used to sign OCI charts in the registry — the modern standard replacing GPG for OCI artifacts.

Admission Controllers: Enforcing Policy Before Entering the Cluster

A security context in your chart is only a default — it doesn't guarantee everyone follows it. What enforces policy hard at the cluster level are admission controllers, especially the two most popular:

OPA/Gatekeeper uses policies written in Rego. A constraint template defines a rule — for example, "all pods must have runAsNonRoot: true" — then a constraint is applied to a specific namespace. An example constraint rejecting pods without runAsNonRoot:

KubernetesGatekeeper ConstraintTemplate (condensed)
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequiredlabels
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredLabels
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredlabels
        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not container.securityContext
          msg := sprintf("container %v must set securityContext", [container.name])
        }

Kyverno offers a more approachable alternative: policies written in pure YAML, not a programming language. The equivalent Kyverno policy — forcing all pods in the prod namespace to run as non-root:

KubernetesKyverno ClusterPolicy - require non-root
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-non-root
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-runasnonroot
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Pods must have securityContext.runAsNonRoot: true"
        anyPattern:
          - spec:
              securityContext:
                runAsNonRoot: true
          - spec:
              containers:
                - securityContext:
                    runAsNonRoot: true

Both work at the same level — rejecting violating resources before they enter the cluster — with trade-offs: Gatekeeper is more flexible (Rego is Turing-complete) but has a steep learning curve; Kyverno is simpler and fits most cases. For chart authors: a good chart and a policy that enforces the standard complement each other — the chart provides safe defaults, the admission controller forces people not to override them.

A Hardened Deployment Template

Now let's combine everything into one deployment template worthy of being an organizational standard:

Kubernetestemplates/deployment.yaml - hardened
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "myapp.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "myapp.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "myapp.fullname" . }}
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 65534
        runAsGroup: 65534
        fsGroup: 65534
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Notice two lines that are often forgotten. automountServiceAccountToken: false removes the pod's access to the API server — an ordinary application doesn't need a ServiceAccount token, and tokens are one of the most common credential-theft vectors. And readOnlyRootFilesystem: true makes the only writable filesystem the mounted volumes — if the application doesn't need to write files in the container, don't allow it.

Conclusion

In this episode 20 we built chart security layer by layer: least privilege via a security context that rejects root and strips capabilities, Pod Security Standards with the restricted profile as baseline, network policies turning the allow-all default into an allowlist, RBAC narrowing ServiceAccount rights, secret management banishing all secrets from Git and routing them through Sealed Secrets or the External Secrets Operator, image security with pull policies and distroless, supply chain security with signing, provenance, and SBOM, and admission controllers enforcing policy at the cluster level.

The core takeaways:

  • Secure by default, not by optionvalues.yaml values must already meet the restricted profile.
  • Drop all capabilities, run as non-root, read-only root filesystem — three lines that save you from most container attacks.
  • No secrets in Git — period. Use external operators and automatic rotation.
  • The smallest RBAC that works — explicit ServiceAccount, narrow verbs, avoid wildcards.
  • Verify what you install — chart signatures, provenance, SBOM, and dependency scanning.
  • The chart is the default, the admission controller is the enforcer — both must work together.

In the next episode, episode 21, we cover multi-environment management: how to manage the same chart for dev, staging, and production without creating three different charts — from values file organization, promotion pipelines, to Helmfile as a complete release declarer. Make sure you understand this episode's security foundation, because in the next episode all those patterns will be practiced across many environments at once. See you in episode 21!

Learn Helm Chart - Chart Security & Best Practices | Learn Helm Chart