StackPractices
intermediate Por Mathias Paulenko

Templating con Helm: empaquetar manifiestos Kubernetes

Construí un Helm chart listo para producción desde cero: values, helpers, condicionales, rangos, hooks, subcharts, empaquetado y registros OCI.

Temas: devops

Overview

Mantener un manifiesto de Kubernetes por entorno no escala. Copiás deployment.yaml, ajustás réplicas y tags de imagen para staging, los volvés a ajustar para producción, y después de unos releases las copias divergen — nadie recuerda cuál es la versión canónica. Helm trata los manifiestos como plantillas: un chart guarda la estructura, values.yaml guarda los valores por defecto y cada entorno recibe un archivo de overrides. Cuando corrés helm install, Helm renderiza las plantillas con tus valores, aplica el resultado al cluster y registra el release para que upgrades y rollbacks sean reproducibles.

Esta receta construye un chart completo — values, helpers, condicionales, rangos, hooks, subcharts y library charts — y después lo empaqueta y distribuye. Para instalar y administrar releases en CI/CD en lugar de crear charts, mirá Desplegar aplicaciones en Kubernetes con Helm Charts.

When to Use

  • Desplegar la misma app a varios entornos (dev, staging, production) donde solo cambia la configuración
  • Compartir un deployment reusable entre equipos — los consumidores sobrescriben values en lugar de editar YAML
  • Administrar el ciclo de vida de una aplicación (install, upgrade, rollback) con seguimiento de releases
  • Parametrizar manifiestos: réplicas, imágenes, límites de recursos, feature toggles por entorno
  • Incorporar herramientas de terceros (Prometheus, Grafana, ingress controllers) a través de sus charts publicados

When NOT to Use

  • Un solo entorno con manifiestos estáticos — un simple kubectl apply -f tiene menos partes móviles
  • Clusters gestionados con GitOps donde ArgoCD o Flux es dueño del ciclo de aplicación (aunque ambos pueden consumir Helm charts)
  • Manifiestos que cambian raramente y no necesitan parametrización
  • Equipos que ya estandarizaron con overlays de Kustomize o jsonnet y no quieren Go templates

Solution

Estructura del chart

my-chart/
├── Chart.yaml          # Metadata del chart y dependencias
├── .helmignore         # Archivos excluidos del chart empaquetado
├── values.yaml         # Values por defecto
├── values/
│   ├── staging.yaml    # Overrides por entorno
│   └── production.yaml
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── configmap.yaml
│   ├── ingress.yaml
│   ├── _helpers.tpl    # Named templates
│   └── NOTES.txt       # Notas post-install
└── charts/             # Dependencias de subcharts descargadas

Chart.yaml

apiVersion: v2
name: my-app
description: A Helm chart for my application
type: application
version: 1.0.0          # Versión del chart
appVersion: "2.5.0"     # Versión de la app
keywords:
  - web
  - api
maintainers:
  - name: Mathias Paulenko
    email: mathias@example.com
dependencies:
  - name: postgresql
    version: 12.x.x
    repository: oci://registry-1.docker.io/bitnamicharts
    condition: postgresql.enabled

values.yaml

replicaCount: 2

image:
  repository: my-app
  tag: "2.5.0"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 8080

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

ingress:
  enabled: false
  className: nginx
  hosts:
    - host: my-app.example.com
      paths:
        - path: /
          pathType: Prefix

config:
  LOG_LEVEL: info
  DATABASE_URL: ""

postgresql:
  enabled: false

Template de Deployment

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.port }}
              protocol: TCP
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          env:
            - name: LOG_LEVEL
              value: {{ .Values.config.LOG_LEVEL | quote }}
            - name: DATABASE_URL
              value: {{ .Values.config.DATABASE_URL | quote }}

Template de Service

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

Helper templates

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

{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{- define "my-app.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{ include "my-app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

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

Bloques condicionales

# templates/ingress.yaml
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "my-app.fullname" . }}
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: {{ .Values.ingress.className }}
  {{- with .Values.ingress.tls }}
  tls:
    {{- toYaml . | nindent 4 }}
  {{- end }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            pathType: {{ .pathType }}
            backend:
              service:
                name: {{ include "my-app.fullname" $ }}
                port:
                  number: {{ $.Values.service.port }}
          {{- end }}
    {{- end }}
{{- end }}

Range sobre values

# templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "my-app.fullname" . }}
data:
  {{- range $key, $value := .Values.config }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}

Values específicos por entorno

# values/production.yaml
replicaCount: 5

image:
  tag: "2.5.0-prod"

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: 2000m
    memory: 2Gi

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: my-app.production.com
      paths:
        - path: /
          pathType: Prefix

config:
  LOG_LEVEL: warn
  DATABASE_URL: "postgres://prod-db:5432/myapp"

Helm hooks

# templates/post-install-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "my-app.fullname" . }}-post-install
  annotations:
    "helm.sh/hook": post-install,post-upgrade
    "helm.sh/hook-weight": "1"
    "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", "up"]
          env:
            - name: DATABASE_URL
              value: {{ .Values.config.DATABASE_URL | quote }}

El chart completo — Chart.yaml, values.yaml, overrides por entorno y todos los templates de abajo — es ejecutable tal cual desde el repositorio complementario.

Instalar y actualizar

# Instalar con values por defecto
helm install my-app ./my-chart

# Instalar con values personalizados
helm install my-app ./my-chart -f values/production.yaml

# Instalar con overrides inline
helm install my-app ./my-chart --set replicaCount=5 --set image.tag=2.5.0

# Upgrade
helm upgrade my-app ./my-chart -f values/production.yaml

# Rollback
helm rollback my-app 1

# Uninstall
helm uninstall my-app

# Template (dry-run, renderiza los manifiestos)
helm template my-app ./my-chart -f values/production.yaml > manifests.yaml

# Lint
helm lint ./my-chart

How It Works

Cuando corrés helm install, el CLI ejecuta cada archivo bajo templates/ como un Go template contra los values fusionados, y después envía los manifiestos renderizados a la API de Kubernetes. Si el apply tiene éxito, Helm guarda el release como un Secret en el cluster — ese registro es lo que permite que helm upgrade, helm rollback y helm history funcionen.

Mermaid flowchart LR diagram

Objetos de contexto del template

Además de .Values, los templates pueden leer varios objetos integrados:

ObjetoQué expone
.ValuesValues fusionados de values.yaml, archivos -f y flags --set
.ReleaseNombre del release, namespace, revisión y estado de install/upgrade
.ChartCampos de Chart.yaml como nombre, versión y appVersion
.CapabilitiesInfo del cluster — versión de Kubernetes y grupos de API disponibles
.FilesArchivos del chart que no son templates, para embeber datos crudos

Precedencia de values

Cuando varias fuentes definen la misma clave, Helm las aplica en orden y gana la última escritura:

  1. values.yaml dentro del chart provee los valores por defecto
  2. Los archivos -f se aplican de izquierda a derecha, así que los últimos sobrescriben a los primeros
  3. Los flags --set, --set-string y --set-file tienen la precedencia más alta

Una clave que nunca se define renderiza como nil. Protegé los bloques opcionales con {{ if .Values.x }}, dales un fallback con {{ .Values.x | default "y" }}, o fallá rápido con {{ required "database.host es requerido" .Values.database.host }} para que el consumidor reciba un error claro en lugar de un manifiesto roto.

Inspeccionar el output renderizado

Renderizá siempre antes de instalar — helm template muestra exactamente lo que va a llegar a la API, y helm show values ./my-chart imprime los defaults fusionados que un consumidor sobrescribiría:

helm template my-app ./my-chart -f values/production.yaml
# Output renderizado (extracto)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  labels:
    helm.sh/chart: my-app-1.0.0
    app.kubernetes.io/name: my-app
    app.kubernetes.io/instance: my-app
    app.kubernetes.io/version: "2.5.0"
    app.kubernetes.io/managed-by: Helm
spec:
  replicas: 5

Empaquetar y distribuir un chart

helm package empaqueta el directorio del chart en un archivo .tgz versionado. Corré primero helm dependency update — resuelve los subcharts en charts/ y escribe Chart.lock, que conviene commitear para que cada build fije las mismas versiones de dependencias. .helmignore funciona como .gitignore para el archivo empaquetado.

helm dependency update ./my-chart   # escribe Chart.lock, descarga subcharts
helm package ./my-chart             # produce my-chart-1.0.0.tgz

# Registros OCI (Helm 3.8+): se sube como una imagen de contenedor
helm push my-chart-1.0.0.tgz oci://registry.example.com/charts

# Repositorio de charts clásico: generar el índice
helm repo index ./repo --url https://charts.example.com

Depurar errores de renderizado

Dos errores concentran la mayoría de los templates rotos:

  • nil pointer evaluating .Values.database.host — el template referenció una clave que no existe. Agregá un default o marcá la clave como required para que Helm falle rápido.
  • error converting YAML to JSON: yaml: line 12 — el template renderizó YAML inválido. Corré helm template e inspeccioná el archivo generado — un quote que faltó o un nindent mal ubicado suele ser la causa.

Para un release ya instalado, helm get manifest my-app imprime los manifiestos renderizados que Helm aplicó por última vez — la forma más rápida de comparar lo que está vivo en el cluster contra lo que produce el chart en local.

Variants

Usar subcharts

# Chart.yaml
dependencies:
  - name: redis
    version: 17.x.x
    repository: oci://registry-1.docker.io/bitnamicharts
    condition: redis.enabled
# values.yaml — configurar el subchart
redis:
  enabled: true
  auth:
    password: "my-redis-password"
  architecture: standalone

Library chart (bloques reusables)

# Chart.yaml
apiVersion: v2
name: common
type: library          # Library chart — no desplegable
version: 1.0.0
# templates/_container.tpl
{{- define "common.container" -}}
- name: {{ .name }}
  image: {{ .image }}
  ports:
    - containerPort: {{ .port }}
  resources:
    {{- toYaml .resources | nindent 4 }}
{{- end }}
# En el template de otro chart
{{- include "common.container" (dict "name" "api" "image" "my-api:1.0" "port" 8080 "resources" .Values.resources) | nindent 8 }}

Helm con sealed secrets

# templates/sealedsecret.yaml
{{- if .Values.sealedSecret.enabled }}
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: {{ include "my-app.fullname" . }}
spec:
  encryptedData:
    database-password: {{ .Values.sealedSecret.encryptedPassword | quote }}
  template:
    metadata:
      name: {{ include "my-app.fullname" . }}
    type: Opaque
{{- end }}

Best Practices

  • Para una guía más profunda, mirá Helm Charts: estructura, templating, dependencias y registry.

  • Preferí include sobre template — solo include devuelve un string que podés encadenar con nindent, y lo necesitás en casi todas partes.

  • Usá siempre nindent en lugar de indent para YAML anidado — antepone el salto de línea, así el bloque queda en su propia línea.

  • Definí resource requests y limits en values.yaml para que cada entorno pueda ajustarlos sin editar templates.

  • Entrecomillá los valores de texto con {{ .Values.x | quote }} — YAML lee yes/no/on/off y números con pinta de versión como booleanos o floats.

  • Mantené values.yaml como el default seguro y poné los overrides por entorno en archivos separados, no en cadenas largas de --set dentro de scripts de CI.

  • Corré helm lint y helm template en CI antes de cualquier helm install — atrapan errores de sintaxis y de renderizado sin tocar el cluster.

  • Fijá las versiones de subcharts en Chart.yaml y commiteá Chart.lock, o un release upstream va a cambiar tus manifiestos renderizados sin avisar.

  • Antes de mergear cambios de charts en un repo compartido, pasá por la checklist de revisión de Helm charts.

Common Mistakes

  • Usar indent donde hace falta nindent: indent indenta las líneas pero no agrega un salto de línea inicial, así que la primera línea queda pegada a la clave padre y el manifiesto sale malformado. Usá nindent por defecto.
  • No entrecomillar values: version: {{ .Values.version }} con version: "2.0" renderiza como version: 2.0, que YAML parsea como float — y on/off/yes/no se vuelven booleanos. Pasá los valores de texto por quote para que queden literales.
  • Hardcodear values en los templates: si un valor varía entre entornos o consumidores, movélo a values.yaml; si no, el chart no se puede reusar y se pierde todo el sentido del templating.
  • Omitir helm lint y helm template en CI: los errores de template aparecen al instalar, muchas veces con el pipeline reteniendo un lock de deployment. Renderizá y linteá primero.
  • Sobrescribir todo con --set: las cadenas largas de --set son difíciles de revisar y filtran valores a los logs de CI. Los archivos pasados con -f quedan versionados, son comparables y más fáciles de auditar.
  • Editar archivos bajo charts/: los subcharts descargados se sobrescriben en el próximo helm dependency update. Modificá el comportamiento a través de values, o forkeá el chart upstream si necesitás cambios reales.

See Also

Preguntas frecuentes

¿Qué es un Helm chart?

Un Helm chart es un paquete de manifiestos de Kubernetes basados en plantillas más un archivo values.yaml. Sobrescribís los values por entorno, instalás con helm install y Helm registra cada despliegue como un release que podés actualizar o revertir.

¿En qué se diferencia Helm de Kustomize?

Helm parametriza manifiestos con Go templates, así que los values pueden venir de flags, archivos o consumidores de un chart compartido. Kustomize evita el templating por completo — apila patches sobre una base. Helm conviene más cuando distribuís charts; Kustomize encaja mejor para overlays de entornos internos del equipo.

¿Qué es un Helm hook?

Un hook es un manifiesto anotado con helm.sh/hook que Helm ejecuta en un punto específico del ciclo de vida del release — pre-install, post-upgrade, pre-rollback, etc. Las migraciones de base de datos y los jobs de backup son el caso de uso clásico.

¿Puedo usar Helm con ArgoCD o Flux?

Sí — ambas herramientas GitOps despliegan Helm charts. ArgoCD los renderiza de forma nativa; Flux usa un CRD HelmRelease. En ese esquema Helm sigue haciendo el templating, pero el controlador GitOps decide cuándo se aplican los cambios.

¿Cómo depuro un template?

Renderizá en local con helm template my-app ./my-chart -f values.yaml, o usá helm install --dry-run --debug para ver los manifiestos y los values calculados sin tocar el cluster. Para un release instalado, helm get manifest muestra lo que Helm aplicó realmente.

¿Dónde publico un Helm chart?

Desde Helm 3.8 la opción recomendada es un registry OCI — subís charts con helm push al mismo registry de contenedores que ya usás. El enfoque anterior también sigue funcionando: hospedá los .tgz y un index.yaml generado detrás de cualquier servidor de archivos estáticos como un repositorio de charts clásico.