Helm Scenario Templates
Use these patterns when a client says, "make this chart support X." Keep values readable, fail early with required, preserve immutable selectors, and render the chart before installing it.
Multi-App Chart Model
This pattern creates one Deployment and one Service per item in .Values.apps. It is useful for small internal platforms where several similar stateless services share one chart. Keep each app name stable because it becomes part of Kubernetes object names and selectors.
global:
imagePullPolicy: IfNotPresent
defaultResources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
apps:
api:
enabled: true
replicas: 3
image:
repository: ghcr.io/example/api
tag: "1.4.2"
containerPort: 8080
service:
type: ClusterIP
port: 80
env:
LOG_LEVEL: info
FEATURE_FLAGS: payments-v2
worker:
enabled: true
replicas: 2
image:
repository: ghcr.io/example/worker
tag: "1.4.2"
containerPort: 9090
service:
type: ClusterIP
port: 9090
command: ["./worker"]
args: ["--queue=payments"]
env:
LOG_LEVEL: debug
Helpers For Multi-App Names
These helpers make every generated app name and label consistent inside a multi-app chart. Use them before writing loops so Deployments, Services, and optional Ingress rules all reference the same names.
{{- define "platform.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "platform.appName" -}}
{{- printf "%s-%s" .root.Release.Name .appName | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "platform.commonLabels" -}}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
{{- end -}}
One Deployment Per App
The $root variable keeps access to release/chart metadata while the loop focuses on each app. Selectors intentionally use only stable labels: release instance and app name.
{{- $root := . -}}
{{- range $appName, $app := .Values.apps }}
{{- if $app.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "platform.appName" (dict "root" $root "appName" $appName) }}
labels:
{{- include "platform.commonLabels" $root | nindent 4 }}
app.kubernetes.io/name: {{ $appName }}
spec:
replicas: {{ default 1 $app.replicas }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ $root.Release.Name }}
app.kubernetes.io/name: {{ $appName }}
template:
metadata:
labels:
app.kubernetes.io/instance: {{ $root.Release.Name }}
app.kubernetes.io/name: {{ $appName }}
spec:
containers:
- name: {{ $appName }}
image: "{{ required (printf "apps.%s.image.repository is required" $appName) $app.image.repository }}:{{ required (printf "apps.%s.image.tag is required" $appName) $app.image.tag }}"
imagePullPolicy: {{ default $root.Values.global.imagePullPolicy $app.image.pullPolicy }}
{{- with $app.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $app.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ required (printf "apps.%s.containerPort is required" $appName) $app.containerPort }}
{{- with $app.env }}
env:
{{- range $key, $value := . }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
resources:
{{- toYaml (default $root.Values.global.defaultResources $app.resources) | nindent 12 }}
{{- end }}
{{- end }}
One Service Per App
This loop creates a Service for each enabled app that has a service block in values. Use it with the Deployment loop so every generated workload gets a predictable in-cluster DNS name.
{{- $root := . -}}
{{- range $appName, $app := .Values.apps }}
{{- if and $app.enabled $app.service }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "platform.appName" (dict "root" $root "appName" $appName) }}
labels:
{{- include "platform.commonLabels" $root | nindent 4 }}
app.kubernetes.io/name: {{ $appName }}
spec:
type: {{ default "ClusterIP" $app.service.type }}
selector:
app.kubernetes.io/instance: {{ $root.Release.Name }}
app.kubernetes.io/name: {{ $appName }}
ports:
- name: http
port: {{ required (printf "apps.%s.service.port is required" $appName) $app.service.port }}
targetPort: http
protocol: TCP
{{- end }}
{{- end }}
Optional Ingress
Use an Ingress when the client needs HTTP routing. For TLS, pair this with cert-manager annotations or a pre-created TLS Secret depending on the platform standard.
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: api.example.com
app: api
path: /
tls:
- secretName: api-example-com-tls
hosts:
- api.example.com
This Ingress template routes host/path entries to the generated per-app Services. Use it when several apps share one chart but need separate HTTP routes or TLS hosts.
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-ingress
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
http:
paths:
- path: {{ default "/" .path }}
pathType: Prefix
backend:
service:
name: {{ $.Release.Name }}-{{ .app }}
port:
number: {{ (index $.Values.apps .app).service.port }}
{{- end }}
{{- end }}
ConfigMap And Existing Secret
Put non-sensitive settings in ConfigMaps. For sensitive values, prefer External Secrets, Sealed Secrets, SOPS, or a pre-created Secret. The chart should usually reference the Secret name instead of storing the secret value.
config:
APP_MODE: production
LOG_FORMAT: json
existingSecret:
name: web-api-secrets
keys:
databaseUrl: DATABASE_URL
apiToken: API_TOKEN
This ConfigMap template converts non-sensitive key/value settings from values into Kubernetes config. Pair it with an existing Secret reference for passwords or tokens rather than storing secret values in Git.
{{- if .Values.config }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
data:
{{- range $key, $value := .Values.config }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
Optional PVC For A Single App
Use this for a simple app needing one mounted volume. For MySQL, PostgreSQL, or anything with stable identity, prefer a StatefulSet and volumeClaimTemplates.
persistence:
enabled: true
storageClassName: gp3
accessModes: ["ReadWriteOnce"]
size: 20Gi
mountPath: /var/lib/app
This PVC template gives a single workload durable storage. Use it for simple persistent needs; for databases such as MySQL or PostgreSQL, prefer a StatefulSet with volumeClaimTemplates.
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-data
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
storageClassName: {{ .Values.persistence.storageClassName | quote }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
Render Checks
Use these checks after changing the values map or template loops. They confirm Helm generated the expected object count and that the Kubernetes API server accepts the rendered output.
helm lint ./platform-chart --strict
helm template platform ./platform-chart -n platform -f values.yaml > /tmp/platform.yaml
# Confirm object count from the multi-app loop.
grep -E '^kind: (Deployment|Service)$' /tmp/platform.yaml
# Ask the API server and admission webhooks to validate the output.
kubectl apply --dry-run=server -f /tmp/platform.yaml