TL;DR

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.

yamlvalues.yaml
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.

gotemplatetemplates/_helpers.tpl
{{- 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.

yamltemplates/deployments.yaml
{{- $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.

yamltemplates/services.yaml
{{- $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.

yamlvalues-ingress.yaml
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.

yamltemplates/ingress.yaml
{{- 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.

yamlvalues-config.yaml
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.

yamltemplates/configmap.yaml
{{- 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.

yamlvalues-pvc.yaml
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.

yamltemplates/pvc.yaml
{{- 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.

bashscenario-checks.sh
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

See also