The with keyword in Helm templates doesn’t just limit variable scope; it fundamentally changes the context of your template execution, making variables relative to a specific piece of data.

Let’s see it in action. Imagine you have a values.yaml like this:

app:
  name: my-awesome-app
  replicas: 3
  port: 8080
  config:
    database:
      host: db.example.com
      port: 5432
    api:
      timeout: 30s

And a deployment.yaml template:

apiVersion: apps/v1
kind: Deployment
metadata:

  name: {{ .Release.Name }}-{{ include "mychart.fullname" . }}

spec:

  replicas: {{ .Values.app.replicas }}

  selector:
    matchLabels:

      app: {{ include "mychart.name" . }}

  template:
    metadata:
      labels:

        app: {{ include "mychart.name" . }}

    spec:
      containers:

      - name: {{ .Values.app.name }}


        image: my-docker-repo/{{ .Values.app.name }}:latest

        ports:

        - containerPort: {{ .Values.app.port }}

        env:
        - name: APP_NAME

          value: {{ .Values.app.name }}

        - name: DB_HOST

          value: {{ .Values.app.config.database.host }}

        - name: DB_PORT

          value: {{ .Values.app.config.database.port | quote }}

        - name: API_TIMEOUT

          value: {{ .Values.app.config.api.timeout }}

This works, but notice how repetitive {{ .Values.app... }} is. Now, let’s refactor with with:

apiVersion: apps/v1
kind: Deployment
metadata:

  name: {{ .Release.Name }}-{{ include "mychart.fullname" . }}

spec:

  replicas: {{ .Values.app.replicas }}

  selector:
    matchLabels:

      app: {{ include "mychart.name" . }}

  template:
    metadata:
      labels:

        app: {{ include "mychart.name" . }}

    spec:
      containers:

      - name: {{ .Values.app.name }}


        image: my-docker-repo/{{ .Values.app.name }}:latest

        ports:

        - containerPort: {{ .Values.app.port }}

        env:
        - name: APP_NAME

          value: {{ .Values.app.name }}


        {{- with .Values.app.config }}

        - name: DB_HOST

          value: {{ .database.host }}

        - name: DB_PORT

          value: {{ .database.port | quote }}

        - name: API_TIMEOUT

          value: {{ .api.timeout }}


        {{- end }}

See how the {{- with .Values.app.config }} block changes things? Inside that block, . now refers to values.app.config. So, .database.host is equivalent to values.app.config.database.host from the outer scope. This makes your templates cleaner, especially when dealing with deeply nested structures.

The real power comes when you combine with with loops, or when you want to conditionally include sections based on the existence of a certain configuration.

Consider a scenario where you only want to expose a service if service.enabled is true and service.port is defined.

# values.yaml
service:
  enabled: true
  port: 80
  targetPort: 8080
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /

And your Kubernetes Service template:


{{- with .Values.service }}


{{- if .enabled }}

apiVersion: v1
kind: Service
metadata:

  name: {{ include "mychart.fullname" $ }}-service

  labels:

    app: {{ include "mychart.name" $ }}

spec:
  selector:

    app: {{ include "mychart.name" $ }}

  ports:
    - protocol: TCP

      port: {{ .port }}


      targetPort: {{ .targetPort }}


{{- if .annotations }}

  annotations:

    {{ toYaml .annotations | nindent 4 }}


{{- end }}

---

{{- end }}


{{- end }}

Here, $ is used to refer back to the top-level scope (the entire Values object) when you need to access something outside the current with context, like include "mychart.fullname" $. Inside the with .Values.service block, . refers to values.service. So, .port is values.service.port, and .annotations is values.service.annotations. The {{- if .enabled }} check is now operating on values.service.enabled.

The with statement, when applied to a map or object, changes the root of the template’s data structure for the duration of that block. When applied to a list, it iterates over the list, and for each item, the . within the with block becomes that item. This is the mechanism behind using with for iterating over lists of configurations, like defining multiple ingress rules or environment variables from a list in your values.yaml.

Crucially, with doesn’t just prepend the parent path; it replaces the current . with the value of the expression. If you have {{ with .Values.app }} ... {{ .name }} ... {{ end }}, the .name inside refers to Values.app.name, not Values.app.name. This is why you often see {{ .Release.Name }} or {{ include "mychart.fullname" $ }} inside a with block when you need to refer to values outside the current context; the $ explicitly tells Helm to go back to the top-level scope.

A common pitfall is forgetting to use $ when you need to reference values from the root scope within a with block that has already changed the context. For example, if you have {{ with .Values.config }} ... {{ .Release.Name }} ... {{ end }}, the {{ .Release.Name }} inside will fail because .Release.Name isn’t defined within the config context. You’d need {{ with .Values.config }} ... {{ $.Release.Name }} ... {{ end }}.

The next step in mastering template logic is understanding how to conditionally render entire blocks of YAML based on complex expressions, often involving eq, and, and or operators within if statements.

Want structured learning?

Take the full Helm course →