Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Helm Parameterization with Values and Template Helpers

Hello. Last time you built a minimal Helm chart for the frontend and rendered its Deployment and Service locally. Its image was configurable, but resource settings were still embedded in the template, and the chart did not describe the backend or the browser routes.

In this lesson, you will extend that uninstalled chart so values control both images, resource settings, non-secret backend configuration, and Ingress rules. Named template helpers will keep resource names and selectors consistent. Allow about 50–60 minutes, including the two documentation readings and local rendering. Nothing here requires an AKS installation.


Decide what belongs in values

A useful Helm value represents something you expect to change between deployments: an image tag, a resource limit, a log level, or an Ingress host. A value is not a safe place for a database password. Helm can render values into manifests and retain release information, so this chart will refer only to the name and key of an existing Kubernetes Secret.

Read the official Helm explanation of defaults and overrides before editing the chart:

Values Files | Helm

Read Helm’s “Values Files” guide to see how chart defaults and an optional environment-specific file combine.

In “Values Files,” begin with the explanation of the Values object and read value precedence, including the example that overrides a default. Then read the nested favorite example in the same section and the opening of “Deleting a default key,” stopping after its recommendation to keep value trees shallow. Focus on why a small override file need not repeat every default.

Keep charts/frontend-lab/Chart.yaml from the previous lesson. Replace values.yaml with the following illustrative defaults. The image repositories and Ingress host are deliberately unusable placeholders; adjust the secret reference to match the Secret you already prepared for the backend.

frontend:
  image:
    repository: replace-me.azurecr.io/frontend
    tag: "replace-with-your-version"
  resources:
    requests:
      cpu: 50m
      memory: 64Mi
    limits:
      cpu: 250m
      memory: 256Mi

backend:
  image:
    repository: replace-me.azurecr.io/backend
    tag: "replace-with-your-version"
  containerPort: 8000
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    limits:
      cpu: 500m
      memory: 512Mi
  config:
    logLevel: INFO
  databaseSecret:
    name: app-db
    key: database-url

ingress:
  enabled: false
  className: nginx
  host: app.example.invalid
  paths:
    api: /api
    websocket: /ws
    frontend: /

The frontend resource defaults preserve those from your first chart. The backend values are lab-sized starting points, not a capacity recommendation; compare them with the limits in your existing backend manifest. If your application listens on another port, uses another database environment-variable name, or exposes different health paths, preserve those real application settings when adapting the templates below.

The ingress.enabled default is false so that an accidental future installation does not create a route alongside your manually managed Ingress. You can still enable it for local rendering with an override.


Make names and selectors reusable

Your chart will now produce two Deployments and two Services. A Service finds Pods by labels, not by Deployment name, so copying selector strings into four files creates an avoidable failure point. A named helper lets each component use the same selector definition while retaining its own frontend or backend component label.

Named Templates | Helm

Read Helm’s “Named Templates” guide for the three details needed here: helper files, passing scope, and indenting included YAML.

In “Partials and _ files,” read helper files. In “Setting the scope of a template,” start where the example asks why the chart name and version disappeared; read passing scope. Finally, in “The include function,” follow the incorrectly indented example through the corrected indentation. Pay particular attention to why include can be piped into nindent.

Create charts/frontend-lab/templates/_helpers.tpl:

{{/* Name of the frontend Deployment and Service. */}}
{{- define "frontend-lab.frontendName" -}}
{{ .Release.Name }}-frontend
{{- end -}}

{{/* Name of the backend Deployment and Service. */}}
{{- define "frontend-lab.backendName" -}}
{{ .Release.Name }}-backend
{{- end -}}

{{/* Labels used to identify one component's Pods. */}}
{{- define "frontend-lab.selectorLabels" -}}
app.kubernetes.io/name: {{ .root.Chart.Name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}

Files beginning with _ supply templates but do not themselves render into Kubernetes objects. The helper names are prefixed with the chart name to reduce naming collisions. The selector helper receives both the chart’s root context and a component name; when calling it, you will pass those explicitly with dict. include ... | nindent ... then inserts its output at the indentation required by each manifest.

Adding a component label changes the frontend Deployment’s selector from the previous chart version. That is fine because you have only rendered the chart, not installed it. Do not treat a selector change as an ordinary upgrade to an already-installed Deployment: Kubernetes Deployment selectors are immutable.


Apply the values to both workloads

Replace templates/deployment.yaml with a frontend template that retains its port and probes but takes its image and resources from values:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "frontend-lab.frontendName" . }}
spec:
  replicas: 1
  selector:
    matchLabels:
      {{- include "frontend-lab.selectorLabels" (dict "root" . "component" "frontend") | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "frontend-lab.selectorLabels" (dict "root" . "component" "frontend") | nindent 8 }}
    spec:
      containers:
        - name: frontend
          image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
          ports:
            - name: http
              containerPort: 80
          resources:
            {{- toYaml .Values.frontend.resources | nindent 12 }}
          readinessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 3
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 10
            periodSeconds: 10

Replace templates/service.yaml to use the same helper for its name and Pod selector:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "frontend-lab.frontendName" . }}
spec:
  type: ClusterIP
  selector:
    {{- include "frontend-lab.selectorLabels" (dict "root" . "component" "frontend") | nindent 4 }}
  ports:
    - name: http
      port: 80
      targetPort: http

Now add templates/backend-deployment.yaml. This example assumes your FastAPI container listens on the configured port, reads LOG_LEVEL and DATABASE_URL, and has the liveness and readiness endpoints shown. Use the environment-variable names and probe paths from your actual backend if they differ.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "frontend-lab.backendName" . }}
spec:
  replicas: 1
  selector:
    matchLabels:
      {{- include "frontend-lab.selectorLabels" (dict "root" . "component" "backend") | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "frontend-lab.selectorLabels" (dict "root" . "component" "backend") | nindent 8 }}
    spec:
      containers:
        - name: backend
          image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
          ports:
            - name: http
              containerPort: {{ .Values.backend.containerPort }}
          env:
            - name: LOG_LEVEL
              value: {{ .Values.backend.config.logLevel | quote }}
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: {{ .Values.backend.databaseSecret.name | quote }}
                  key: {{ .Values.backend.databaseSecret.key | quote }}
          resources:
            {{- toYaml .Values.backend.resources | nindent 12 }}
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            periodSeconds: 10

Add templates/backend-service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "frontend-lab.backendName" . }}
spec:
  type: ClusterIP
  selector:
    {{- include "frontend-lab.selectorLabels" (dict "root" . "component" "backend") | nindent 4 }}
  ports:
    - name: http
      port: 80
      targetPort: http

toYaml renders each resource map as Kubernetes YAML; nindent 12 puts requests and limits beneath the container’s resources: field. The backend Service exposes port 80 inside the cluster and forwards to the container’s named http port, whose number comes from backend.containerPort.

The Secret reference contains no password. It also does not create the Secret: a later installation needs the named Secret and key to exist in the release’s namespace. Keep secret values out of values.yaml, override files, command-line --set arguments, and rendered-output files you might commit.

A React/Vite frontend needs one further distinction. Variables compiled into a static frontend bundle during its image build do not change when Helm changes a Pod environment variable. For these routes, the frontend should call relative /api and /ws URLs—or use a runtime configuration mechanism you have actually implemented—rather than relying on a Helm value to rewrite an already-built bundle.


Parameterize the Ingress without changing traffic yet

The diagram shows the role of Ingress: it selects a Service from the request’s host and path, while that Service selects Pods. Your application uses /api and /ws for the backend and / for the frontend rather than the diagram’s example paths.

Example Ingress path routing: requests for two URL paths reach different Services, which distribute them to Pods. In this chart, `/api` and `/ws` point to the backend Service, while `/` points to the frontend Service.

Create templates/ingress.yaml:

{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .Release.Name }}-app
spec:
  ingressClassName: {{ .Values.ingress.className | quote }}
  rules:
    - host: {{ .Values.ingress.host | quote }}
      http:
        paths:
          - path: {{ .Values.ingress.paths.api | quote }}
            pathType: Prefix
            backend:
              service:
                name: {{ include "frontend-lab.backendName" . }}
                port:
                  name: http
          - path: {{ .Values.ingress.paths.websocket | quote }}
            pathType: Prefix
            backend:
              service:
                name: {{ include "frontend-lab.backendName" . }}
                port:
                  name: http
          - path: {{ .Values.ingress.paths.frontend | quote }}
            pathType: Prefix
            backend:
              service:
                name: {{ include "frontend-lab.frontendName" . }}
                port:
                  name: http
{{- end }}

This is path forwarding, not path rewriting: a request for /api/items reaches the backend with that path. Set these paths to match routes your backend actually serves. /ws also needs an Ingress controller that supports WebSocket connections; rendering an Ingress cannot verify an upgraded connection. Before an installation, check that className matches your controller and replace the example host with the host you intend to use. The example contains no TLS configuration and is not suitable for sending real credentials over a public HTTP endpoint.

For a concrete local override, create charts/frontend-lab/values-lab.yaml with non-secret values appropriate to your lab. For example:

frontend:
  image:
    repository: replace-me.azurecr.io/frontend
    tag: "a-tag-you-pushed"
backend:
  image:
    repository: replace-me.azurecr.io/backend
    tag: "a-tag-you-pushed"
  config:
    logLevel: DEBUG
ingress:
  enabled: true
  host: app.example.invalid

An override file changes only the keys it supplies; the resource requests and path defaults still come from values.yaml. These example repositories, tags, and host let you inspect templating, but do not make the output deployable.

Cost of this practical activity: editing chart files, helm lint, and helm template locally creates no new Azure charge. Existing AKS nodes, registry storage, databases, and networking resources continue to incur their normal charges until cleaned up. Do not install this chart or provision an Ingress controller for this activity.

From the repository root, run:

helm lint ./charts/frontend-lab -f ./charts/frontend-lab/values-lab.yaml

helm template ui-lab ./charts/frontend-lab \
  --namespace app-lab \
  -f ./charts/frontend-lab/values-lab.yaml

Inspect the rendered YAML for five objects: frontend and backend Deployments, frontend and backend Services, and one Ingress. Both Services must select the labels on their respective Pods. The Ingress must point to ui-lab-backend for /api and /ws, and ui-lab-frontend for /. Confirm that the backend image tag and LOG_LEVEL reflect the override, while its resources reflect the defaults.

For comparison, rendering without the override should produce four objects and no Ingress:

helm template ui-lab ./charts/frontend-lab --namespace app-lab

Rendering checks the chart’s output, not whether an image exists, a Secret is present, the probes succeed, or browser requests reach the right service. In particular, do not install this alongside the manually managed application yet: its Services have release-based names, and a second Ingress with overlapping host and paths could compete with the existing route.


What you have built

The chart now has one set of defaults for two container images, their resource settings, non-secret backend configuration, and optional Ingress paths. Named helpers make each Deployment, Service, and route refer consistently to the same component, while the database credential remains outside the chart.

Keep the locally rendered output for comparison with your existing manifests. In the next lesson, you will validate the chart as a release, plan the transition from manually managed resources, and then inspect upgrades and rollbacks on AKS.

Can't find a good explanation? Sign up and we'll make it for you

Sign up