Write Your First Helm Chart

Khimananda Oli 8 min read Virtualization
Write Your First Helm Chart

By Khimananda Oli | Last reviewed: August 2026

Managing raw Kubernetes manifests quickly becomes unmanageable as environments multiply and configuration drift sets in. When you write your first Helm chart, you move from static YAML files to a parameterized, version-controlled package that adapts to dev, staging, and production without duplication. This guide walks through creating a functional chart from scratch, focusing on the template mechanics and values structure that actually matter in production.

values.yamlUser Configimage.tag: v1.2replicas: 3env: productiontemplates/deployment.yamlservice.yaml_helpers.tplingress.yamlRendered K8sDeploymentServiceIngressConfigMapMergeRender
Helm merges values.yaml with templates to produce environment-specific Kubernetes manifests when you write your first Helm chart

How do you scaffold a new Helm chart correctly?

The helm create command generates a production-grade skeleton that follows established conventions. While many tutorials treat this as a mere starting point, understanding what it creates prevents structural mistakes later. If you are also exploring Helm charts for Kubernetes packaging, recognizing this standard layout helps you read third-party charts confidently.

helm create my-app
cd my-app
tree .

This produces the canonical structure:

  • Chart.yaml: Metadata including name, version (SemVer), appVersion, and dependencies.
  • values.yaml: Default configuration values; this is your primary interface.
  • templates/: Go template files that generate Kubernetes manifests.
  • templates/_helpers.tpl: Reusable template definitions and naming conventions.
  • charts/: Directory for dependent sub-charts.
  • .helmignore: Patterns for files to exclude from packaging.

A common mistake is modifying the generated deployment template without understanding its helper functions. The default _helpers.tpl defines my-app.fullname, my-app.labels, and my-app.selectorLabels. These ensure consistent labeling across all resources, which is critical for service discovery and upgrades. Never hardcode names or labels directly in resource templates; always reference these helpers to maintain upgrade safety.

How do you manage configuration with values.yaml?

The values.yaml file is the contract between chart authors and users. When you write your first Helm chart, structure this file for clarity and override-friendliness rather than exhaustive documentation. Group related settings logically and use nested maps for complex configurations.

# values.yaml
replicaCount: 2

image:
  repository: nginx
  tag: "1.25-alpine"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

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

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 80

In practice, avoid flat key structures like imageRepository and imageTag. Nested objects allow selective overrides: a user can change only image.tag in a staging values file without re-specifying the repository. Always provide sensible defaults that work out-of-the-box for local development; environment-specific concerns belong in separate override files passed via -f staging-values.yaml.

For teams managing multiple environments, consider the precedence order: built-in defaults < chart values.yaml < parent chart values < user-supplied values files < --set flags. Document non-obvious keys in comments directly above them, but keep comments concise. If a value requires extensive explanation, it likely needs restructuring or belongs in external documentation linked from Chart.yaml.

How do you write safe and reusable Helm templates?

Helm uses Go’s text/template engine with Sprig functions. The most frequent errors when engineers write their first Helm chart stem from misunderstanding scope, missing required fields, or producing invalid YAML. Always wrap template expressions that output strings in quotes unless the field explicitly requires an unquoted integer or boolean.

# 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:
            - containerPort: {{ .Values.service.targetPort }}
              protocol: TCP
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Key patterns to internalize:

  1. Use nindent over indent: nindent adds a newline before indenting, preventing YAML parsing errors when blocks follow other keys.
  2. Quote string outputs: Image tags, annotations, and label values should be quoted to handle special characters and numeric-looking strings safely.
  3. Validate required values: Use required "message" .Values.someKey to fail fast with a clear error instead of rendering empty strings.
  4. Prefer toYaml for nested objects: Resources, node selectors, tolerations, and affinity rules should be injected as complete YAML blocks rather than reconstructed key-by-key.
values.yamlDefault + Override_helpers.tplNamed TemplatesGo Template EngineSprig FunctionsScope Resolution (.Values)Raw YAML OutputUnvalidated ManifestsValidated K8shelm lint / templateInjectResolveRenderValidate
Template rendering pipeline: values and helpers feed the Go engine, producing YAML that must be validated before cluster application

Always test templates locally before pushing. The helm template ./my-app -f staging-values.yaml command renders manifests without contacting a cluster, letting you inspect the exact output. Pair this with helm lint ./my-app to catch structural issues, undefined references, and YAML syntax errors. In CI pipelines, add both commands as mandatory gates; catching template failures pre-deploy saves hours of debugging broken releases.

How do you validate and test your Helm chart before release?

Validation separates working charts from fragile ones. Beyond basic linting, adopt a layered testing approach that mirrors how you would test application code. For teams integrating Helm into CI/CD pipeline automation workflows, automated chart validation prevents configuration regressions from reaching production clusters.

Validation MethodPurposeWhen to Run
helm lintCatches syntax errors, missing required values, and structural issuesEvery commit, pre-commit hook
helm templateRenders full manifests for visual inspection and diffingPR review, CI artifact generation
helm install --dry-runSimulates installation against live cluster API without applyingPre-deployment verification
ct lint/installChart Testing tool validates version bumps and fresh installsPR checks for chart changes
kubeval / kubeconformValidates rendered YAML against Kubernetes OpenAPI schemasCI pipeline post-render step

A critical practice often overlooked is testing upgrade paths. Run helm upgrade --install my-app ./my-app --dry-run against an existing release to verify that label selectors remain stable and persistent volume claims aren’t accidentally recreated. Selector immutability is the most common cause of failed upgrades; if you change matchLabels in a Deployment, Kubernetes rejects the update. Always include selector labels in your _helpers.tpl and never expose them as configurable values.

For charts intended for team-wide use, add unit tests using the helm-unittest plugin. Tests assert that specific values produce expected manifest fields, catching logic errors in conditionals and range loops. This is especially valuable when supporting multiple Kubernetes versions or cloud providers where API fields differ.

How do you package and distribute your Helm chart?

Once validated, package your chart for distribution. The helm package ./my-app command creates a versioned .tgz archive named according to Chart.yaml. Versioning follows Semantic Versioning strictly: increment the major version for breaking changes (removed values, renamed templates), minor for backward-compatible additions, and patch for bug fixes.

# Update version in Chart.yaml before packaging
version: 0.2.0
appVersion: "1.25.0"

# Package and push to OCI registry (recommended for 2026)
helm package ./my-app
helm push my-app-0.2.0.tgz oci://registry.example.com/charts

OCI registries have become the standard for chart distribution, replacing legacy HTTP repositories. They integrate with existing container registry infrastructure, support authentication via standard Docker credentials, and enable signed artifacts using cosign or Notation. If you are exploring AI-assisted IaC generation, OCI-based charts simplify pipeline integration since the same registry handles both images and charts.

Always sign your chart packages before publishing. Supply chain security is non-negotiable in 2026; unsigned charts should be treated as untrusted. Use helm signtool or cosign to attach signatures, and configure your CI to verify signatures before deployment. Document the signing key fingerprint in your chart’s README so consumers can validate authenticity independently.

DevelopEdit TemplatesUpdate ValuesLocal TestValidatehelm linthelm templateUnit TestsPackagehelm packageSign ArtifactBump VersionDistributeOCI RegistrySigned PushRelease NotesCommitPassPublish
Complete chart lifecycle: development, validation gates, signed packaging, and OCI distribution ensure reliable Kubernetes deployments

Write Your First Helm Chart With Production Discipline

Writing your first Helm chart is straightforward; writing one that survives production demands discipline around templating safety, values design, and validation rigor. Start with helm create, respect the helper conventions, structure values for override flexibility, and never skip linting and dry-run testing. Treat your chart as software: version it semantically, test it automatically, sign it cryptographically, and document its contract clearly. If you need help designing charts that meet compliance requirements or integrate with existing GitOps workflows, reach out to discuss your infrastructure packaging needs.

Frequently Asked Questions

You need a Chart.yaml metadata file, a templates directory for Kubernetes manifests, and an optional values.yaml for configuration overrides. The helm create command generates this scaffold automatically with example deployments and services included.

Run helm lint to check template rendering and structural errors locally. Use helm template to preview generated YAML without cluster access. Both commands catch missing values, invalid references, and formatting issues before deployment attempts fail in production environments.

No, Helm templates cannot access host environment variables directly. Pass them via --set flags or external values files during installation. For CI pipelines, inject secrets through value overrides or integrate with external secret managers like External Secrets Operator.

Chart.yaml defines chart metadata including name, version, and dependencies. Values.yaml provides default configuration parameters that users override at install time. Never store runtime config in Chart.yaml as it controls packaging identity rather than application behavior settings.

Never commit plaintext secrets to values.yaml. Use sealed-secrets, SOPS encryption, or External Secrets Operator to inject credentials at runtime. Reference secret names in templates and let GitOps tools handle decryption during deployment to maintain repository security compliance.

Use Helm 3.17 or later for full OCI registry support and improved dependency management. Avoid Helm 2 entirely as it reached end-of-life years ago. Current stable releases include better schema validation and enhanced template functions for modern Kubernetes clusters.

Yes, run helm template to render manifests locally.

Helper templates define reusable snippets like labels, selectors, and resource names in _helpers.tpl. Use them to maintain consistency across multiple resources and reduce duplication. They accept arguments and return formatted strings that standardize naming conventions throughout your entire chart structure.

Declare dependencies in Chart.yaml under the dependencies section specifying repository URL and version constraints. Run helm dependency update to download charts into the charts subdirectory. Pin exact versions in production to prevent unexpected breaking changes during future dependency resolution operations.

This indicates missing CRDs or API version mismatches. Verify target cluster has required operators installed and API versions match your templates. Check kubectl api-resources output against your manifest kind fields to identify unsupported or deprecated resource types causing validation failures.

Use dot notation with --set flag like --set image.tag=v2. For complex structures, pass JSON via --set-json or provide override files with -f. Command-line values always take precedence over defaults in values.yaml enabling flexible environment-specific configurations without modifying source templates.

Helm suits parameterized applications needing reuse across environments.

Follow semantic versioning in Chart.yaml where major bumps indicate breaking template changes. Increment minor versions for backward-compatible features and patch versions for bug fixes. Always update appVersion separately to track deployed application releases independently from chart packaging iterations and dependency updates.

Beginners often hardcode values instead of parameterizing them, skip linting steps, misuse indentation in YAML templates, or forget to quote string values containing special characters. Always validate with helm lint, use named templates for repetition, and document all configurable parameters in README files.

Use helm template --debug to see rendered output with error context. Add {{ printf "%#v" .Values }} statements temporarily to inspect data structures. Check values hierarchy precedence and ensure referenced keys exist. Remove debug statements before committing to avoid exposing sensitive configuration data in logs.