TL;DR

A production chart usually starts with Chart.yaml, values.yaml, helpers, Deployment or StatefulSet templates, Service, config, ingress, autoscaling, disruption budget, and security controls. Do not create every file by habit; add a template when the workload or platform contract needs it.

Chart Root Files

These files live at the chart root. They define chart metadata, default configuration, dependency locks, packaged subcharts, CRDs, and files excluded from chart packages.

textchart-root.txt
web-api/
  Chart.yaml          # Chart metadata, type, version, dependencies.
  Chart.lock          # Locked dependency versions after helm dependency update.
  values.yaml         # Default chart API; safe non-secret defaults.
  values-dev.yaml     # Local/dev overrides.
  values-prod.yaml    # Production sizing, ingress, PDB, resources.
  README.md           # Human usage notes and supported values.
  .helmignore         # Files excluded from packaged chart.
  charts/             # Vendored dependencies.
  crds/               # CRDs installed before templates.
  templates/          # Templated Kubernetes objects.
yamlChart.yaml
apiVersion: v2
name: web-api
description: Web API application chart
type: application
version: 1.2.0
appVersion: "2.4.7"
dependencies:
  - name: redis
    version: "19.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled

Template Inventory

This is the normal menu of files to consider inside templates/. A small HTTP service may only need Deployment, Service, helpers, and optional Ingress. Stateful apps and platform charts usually need more.

TemplatePurposeAdd when...
_helpers.tplReusable names, labels, selectors, and annotation helpers.Always, for consistent metadata.
deployment.yamlStateless application Pods managed by a Deployment.API, web, worker, consumer, frontend services.
service.yamlStable DNS and virtual IP for Pods.Pods receive traffic from other Pods or ingress.
serviceaccount.yamlDedicated Pod identity.Pods need RBAC, IRSA, Workload Identity, or non-default identity.
configmap.yamlNon-sensitive config, feature flags, app files.Config should change independently from image.
secret.yamlKubernetes Secret object.Only for generated or injected secrets; avoid raw Git secrets.
ingress.yamlHTTP routing from outside the cluster.App needs hostname, TLS, or path routing.
hpa.yamlHorizontal scaling from metrics.Workload can scale horizontally and metrics exist.
pdb.yamlMinimum availability during voluntary disruptions.Replicas > 1 and node drains should preserve availability.
networkpolicy.yamlPod ingress/egress rules.CNI enforces NetworkPolicy and workload traffic is known.
statefulset.yamlStable identity and ordered rollout for stateful Pods.Databases, queues, clustered systems.
pvc.yamlPersistent storage claim for a workload.A single Deployment needs durable storage.
job.yamlOne-shot task.Migration, seed, setup, cleanup.
cronjob.yamlScheduled task.Periodic cleanup, sync, report, backup.
tests/*.yamlHelm test Pods or Jobs.Release should include smoke checks.
NOTES.txtPost-install message.Operators need commands, URLs, or follow-up steps.

_helpers.tpl

Helpers are not rendered directly. They keep names, labels, and selectors stable across every manifest in the chart.

gotemplatetemplates/_helpers.tpl
{{- define "app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "app.fullname" -}}
{{- printf "%s-%s" .Release.Name (include "app.name" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

deployment.yaml

Use Deployments for stateless apps. This is where most production values appear: image, resources, probes, security contexts, lifecycle, scheduling, and termination behavior.

yamltemplates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "app.fullname" . }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "app.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "app.fullname" . }}
      terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          startupProbe:
            {{- toYaml .Values.startupProbe | nindent 12 }}
          readinessProbe:
            {{- toYaml .Values.readinessProbe | nindent 12 }}
          livenessProbe:
            {{- toYaml .Values.livenessProbe | nindent 12 }}

service.yaml

A Service gives Pods a stable DNS name and decouples callers from Pod IPs. Use named ports so Ingress and probes can refer to http instead of hard-coded numbers.

yamltemplates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ include "app.fullname" . }}
spec:
  type: {{ .Values.service.type }}
  selector:
    {{- include "app.selectorLabels" . | nindent 4 }}
  ports:
    - name: http
      port: {{ .Values.service.port }}
      targetPort: http

configmap.yaml And secret.yaml

Put non-sensitive settings in ConfigMaps. Prefer existing or external secret systems for passwords and tokens; if the chart creates a Secret, keep it behind an explicit values switch.

yamltemplates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "app.fullname" . }}
data:
  LOG_LEVEL: {{ .Values.config.logLevel | quote }}
  FEATURE_FLAGS: {{ .Values.config.featureFlags | quote }}
yamltemplates/secret.yaml
{{- if .Values.secret.create }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ include "app.fullname" . }}
type: Opaque
stringData:
  DATABASE_URL: {{ .Values.secret.databaseUrl | quote }}
{{- end }}

serviceaccount.yaml And RBAC

A named ServiceAccount makes identity explicit. Add Role and RoleBinding only when the Pod actually talks to the Kubernetes API.

yamltemplates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ include "app.fullname" . }}
  annotations:
    {{- toYaml .Values.serviceAccount.annotations | nindent 4 }}
yamltemplates/role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: {{ include "app.fullname" . }}
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list"]

ingress.yaml

Ingress exposes HTTP/S routes. Keep it conditional because internal workers and private services often do not need external routing.

yamltemplates/ingress.yaml
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "app.fullname" . }}
  annotations:
    {{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  rules:
    - host: {{ .Values.ingress.host | quote }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ include "app.fullname" . }}
                port:
                  name: http
{{- end }}

hpa.yaml And pdb.yaml

HPA adjusts replica count from metrics. PDB protects availability during voluntary disruptions such as node drains, but it only makes sense when enough replicas exist.

yamltemplates/hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "app.fullname" . }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "app.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
yamltemplates/pdb.yaml
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ include "app.fullname" . }}
spec:
  minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
  selector:
    matchLabels:
      {{- include "app.selectorLabels" . | nindent 6 }}
{{- end }}

mysql-statefulset.yaml

Use StatefulSets for databases and clustered systems that need stable Pod names, ordered rollouts, and per-Pod storage. A headless Service gives stable DNS such as mysql-0.mysql.

yamltemplates/mysql-statefulset.yaml
{{- if .Values.mysql.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  serviceName: mysql
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: mysql
  template:
    metadata:
      labels:
        app.kubernetes.io/name: mysql
    spec:
      containers:
        - name: mysql
          image: "{{ .Values.mysql.image.repository }}:{{ .Values.mysql.image.tag }}"
          ports:
            - name: mysql
              containerPort: 3306
          envFrom:
            - secretRef:
                name: mysql
          volumeMounts:
            - name: data
              mountPath: /var/lib/mysql
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: {{ toYaml .Values.mysql.persistence.accessModes | nindent 10 }}
        resources:
          requests:
            storage: {{ .Values.mysql.persistence.size }}
{{- end }}

networkpolicy.yaml

NetworkPolicy restricts Pod traffic when the CNI supports enforcement. Start with ingress allow rules for known callers, then add egress rules only when destinations are understood.

yamltemplates/networkpolicy.yaml
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: {{ include "app.fullname" . }}
spec:
  podSelector:
    matchLabels:
      {{- include "app.selectorLabels" . | nindent 6 }}
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - port: http
{{- end }}

job.yaml And cronjob.yaml

Jobs handle one-time work such as migrations or seed data. CronJobs handle repeated work such as cleanup, reports, and syncs. Hook annotations can make a Job run during Helm lifecycle events.

yamltemplates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "app.fullname" . }}-migrate
  annotations:
    helm.sh/hook: pre-install,pre-upgrade
    helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./migrate"]
yamltemplates/cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: {{ include "app.fullname" . }}-cleanup
spec:
  schedule: {{ .Values.cleanup.schedule | quote }}
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: cleanup
              image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
              command: ["./cleanup"]

tests And NOTES.txt

Helm tests give operators a release-level smoke check. NOTES.txt prints useful follow-up commands after install or upgrade.

yamltemplates/tests/smoke.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "app.fullname" . }}-smoke"
  annotations:
    helm.sh/hook: test
spec:
  restartPolicy: Never
  containers:
    - name: curl
      image: curlimages/curl:8.7.1
      command: ["curl", "-f", "http://{{ include "app.fullname" . }}:{{ .Values.service.port }}/healthz"]

Production values.yaml Shape

Expose knobs that operators can safely change per environment. Keep raw passwords out of Git, use immutable image tags or digests, and make availability settings match actual replica counts.

yamlvalues-prod.yaml
replicaCount: 3

image:
  repository: registry.example.com/platform/web-api
  tag: "sha-abc123"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    memory: 512Mi

podSecurityContext:
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

startupProbe:
  httpGet:
    path: /healthz
    port: http
  failureThreshold: 30
  periodSeconds: 5
readinessProbe:
  httpGet:
    path: /readyz
    port: http
  periodSeconds: 10
livenessProbe:
  httpGet:
    path: /healthz
    port: http
  periodSeconds: 20

terminationGracePeriodSeconds: 30
lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 10"]

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

podDisruptionBudget:
  enabled: true
  minAvailable: 2

affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          topologyKey: kubernetes.io/hostname
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: web-api

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: ScheduleAnyway
    labelSelector:
      matchLabels:
        app.kubernetes.io/name: web-api

nodeSelector: {}
tolerations: []

serviceAccount:
  annotations: {}

ingress:
  enabled: true
  className: nginx
  host: api.example.com
  annotations: {}

config:
  logLevel: info
  featureFlags: "checkout-v2"

secret:
  create: false
  existingSecret: web-api-secrets

mysql:
  enabled: false
  image:
    repository: mysql
    tag: "8.0.36"
  persistence:
    accessModes: ["ReadWriteOnce"]
    size: 20Gi

Gotchas

  • !Do not template immutable selectors casually: changing Deployment or Service selectors can force replacement or break traffic.
  • !CPU limits are optional for many services: memory limits are important; CPU limits can create throttling if set without measurement.
  • !PDB and HPA must agree: a strict PDB with too few replicas blocks node drains.
  • !StatefulSets are not just Deployments with disks: storage, backup, restore, upgrades, and DNS identity need explicit operational design.
  • !Raw secrets do not belong in committed values: prefer External Secrets, Sealed Secrets, SOPS, or pre-created Secrets.