Skip to main content
ship it and sleep

Helm Charts, Values Layering, and Dependency Management

4 min read Chapter 38 of 66

Helm Charts, Values Layering, and Dependency Management

The Failure

The team created a shared Helm chart for all microservices. The values.yaml grew to 800 lines. Every team added their service-specific values to the shared defaults. When the platform team changed a default value, 4 out of 12 services broke because they depended on the old default. The shared chart became a coordination bottleneck.

The fix: keep the shared chart minimal (deployment, service, HPA, PDB). Service-specific configuration lives in per-service values files, not in the chart defaults.

The Mechanism

Values Layering

Helm merges values files in order. Later files override earlier ones:

helm template my-service ./charts/microservice \
  -f charts/microservice/values.yaml \        # Chart defaults
  -f values/common.yaml \                      # Organization defaults
  -f values/checkout-service.yaml \             # Service-specific
  -f values/checkout-service-production.yaml    # Environment-specific

Chart Dependencies

A service chart can depend on other charts (Redis, PostgreSQL for local development):

# Chart.yaml
dependencies:
  - name: redis
    version: "18.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled

The Implementation

Shared Microservice Chart

# charts/microservice/Chart.yaml
apiVersion: v2
name: microservice
description: Shared chart for e-commerce microservices
version: 1.0.0
# charts/microservice/values.yaml
# HARDENED: Minimal defaults — services override what they need
replicaCount: 1

image:
  repository: "" # Required: must be set per service
  tag: "" # Required: must be set per deployment
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 8080

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

healthCheck:
  readiness:
    path: /health/ready
    initialDelaySeconds: 5
    periodSeconds: 10
  liveness:
    path: /health/live
    initialDelaySeconds: 15
    periodSeconds: 20

autoscaling:
  enabled: false
  minReplicas: 1
  maxReplicas: 5
  targetCPUUtilization: 80

podDisruptionBudget:
  enabled: false
  minAvailable: 1
# charts/microservice/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
  labels:
    {{- include "microservice.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "microservice.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "microservice.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Release.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: {{ .Values.service.port }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          readinessProbe:
            httpGet:
              path: {{ .Values.healthCheck.readiness.path }}
              port: {{ .Values.service.port }}
            initialDelaySeconds: {{ .Values.healthCheck.readiness.initialDelaySeconds }}
            periodSeconds: {{ .Values.healthCheck.readiness.periodSeconds }}
          livenessProbe:
            httpGet:
              path: {{ .Values.healthCheck.liveness.path }}
              port: {{ .Values.service.port }}
            initialDelaySeconds: {{ .Values.healthCheck.liveness.initialDelaySeconds }}
            periodSeconds: {{ .Values.healthCheck.liveness.periodSeconds }}
          {{- if .Values.env }}
          env:
            {{- toYaml .Values.env | nindent 12 }}
          {{- end }}

Per-Service Values

# values/checkout-service.yaml
image:
  repository: ghcr.io/acme/checkout-service

service:
  port: 8080

resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    cpu: 1000m
    memory: 1Gi

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilization: 70

podDisruptionBudget:
  enabled: true
  minAvailable: 1

env:
  - name: CATALOG_SERVICE_URL
    value: http://catalog-service.production.svc.cluster.local
  - name: INVENTORY_SERVICE_URL
    value: http://inventory-service.production.svc.cluster.local

Chart Testing in CI

# HARDENED: Helm chart validation
- name: Lint chart
  run: helm lint charts/microservice

- name: Template with each service's values
  run: |
    for values_file in values/*.yaml; do
      SERVICE=$(basename "$values_file" .yaml)
      echo "Templating $SERVICE..."
      helm template "$SERVICE" charts/microservice \
        -f "$values_file" \
        --set image.tag=test \
        | kubectl apply --dry-run=server -f -
    done

- name: Run chart tests
  run: |
    helm install test-release charts/microservice \
      -f values/checkout-service.yaml \
      --set image.tag=test \
      --dry-run

The Gate

Chart linting and template rendering run in CI on every PR. If a chart change breaks any service’s values file, the PR is blocked. The kubectl apply --dry-run=server validates against the cluster’s API schema.

Chart version bumps require explicit version changes in Chart.yaml. SemVer is enforced: breaking changes require a major version bump.

The Recovery

Chart upgrade breaks a service: Pin the chart version per service. Each service’s ArgoCD Application specifies the chart version. Upgrade one service at a time.

Values file merge produces unexpected results: Use helm template with --debug to see the full rendered output. Helm’s merge behavior can be surprising with nested objects — use explicit keys, not deeply nested structures.