DEV Community

sanjay sankhla
sanjay sankhla

Posted on

The Helm Chart Is a Platform Contract — Not a Template

Early in building our cloud infrastructure, we had a problem nobody talks about — because it happens so slowly you almost don't notice it.

We had eight separate Helm charts.

One for services that needed KEDA scaling. One for standard HPA. One for backends that exposed HTTP. One for workers that didn't. One for Azure Functions. One for frontends. Eight charts, all living in the same repository, all drifting apart from each other.

The charts started as copies of each other. Over time each one picked up its own fixes, its own conventions, its own slightly-different take on security contexts and ServiceAccount annotations and rolling update strategy. Nobody made a decision to diverge. It just happened.

Every time we fixed something in one chart — say, wiring up Azure Workload Identity to every ServiceAccount — we had to remember to propagate that fix to seven others. Sometimes we did. Sometimes we didn't. We'd find out when something broke in an unexpected way six weeks later.

Helm chart drift is more dangerous than dependency drift. At least with a dependency, you know what version you're on. With eight loosely related charts, you just don't know what you don't know.

This is the story of how we replaced all eight with a single versioned chart, published to an OCI registry, and consumed by 70+ services through ArgoCD multi-source Applications — and what that structure forced us to think clearly about.


The Two-Questions Framework

The first thing we had to do was figure out why we had eight charts in the first place. What was actually different between services that justified a different chart?

We landed on two questions:

  1. Does it expose HTTP? — This determines whether it needs an ingress, a Service, liveness/readiness probes on an HTTP path.
  2. What drives its scaling? — Standard CPU/memory HPA, or event-driven scaling via KEDA (Azure Service Bus, Event Hubs)?

That's it. Everything else — security contexts, Workload Identity, pod anti-affinity, rolling update strategy, how secrets are mounted — is the same for every service. There was no reason for it to differ. It only differed because nobody had said it shouldn't.

From those two questions, we get three chart archetypes:

Chart Who uses it
platform-backend Backend services, workers, queue processors
platform-function-app Containerised Azure Functions
platform-frontend Frontend web applications

platform-backend is the one that carries most of the complexity. The other two are simpler. Everything below focuses on it.


What the Chart Enforces (Non-Negotiables)

The key design decision was separating things the platform cares about from things service teams care about.

Platform concerns belong in the chart. They are not options. Service teams do not get to turn them off.

Azure Workload Identity on every ServiceAccount

Every ServiceAccount the chart creates gets the Workload Identity annotations baked in:

# values.yaml (chart defaults)
serviceAccount:
  create: false
  annotations:
    azure.workload.identity/client-id: "<client_id>"
    azure.workload.identity/tenant-id: "<tenant-id>"
  labels:
    azure.workload.identity/use: "true"
Enter fullscreen mode Exit fullscreen mode

The client-id is per-service (it's in the values file the team manages). The label that enables the OIDC token injection is not — it's always there.

Pod anti-affinity across nodes by default

# values.yaml (chart defaults)
podAntiAffinity:
  enabled: true
  topologyKey: "kubernetes.io/hostname"
Enter fullscreen mode Exit fullscreen mode

Services spread across nodes by default. Teams can disable this for workers that don't need it, but they have to make an active choice.

Rolling update strategy

rollingUpdate:
  maxUnavailable: ""
  maxSurge: ""
Enter fullscreen mode Exit fullscreen mode

The chart enforces a rolling update strategy. Recreate is not on the table.

DB migrations as a PreSync hook

If a service has database migrations, those run before the deployment rolls out — not after, not alongside, not "we'll figure it out manually":

argoHooks:
  dbMigration:
    enabled: false
    command:
    - "/app/migrate.sh"
Enter fullscreen mode Exit fullscreen mode

When enabled: true, the chart creates an ArgoCD PreSync Job that runs the migration command using the same container image as the service. The deployment only proceeds once the Job succeeds.


The ArgoCD Multi-Source Pattern: Decoupling Chart from Config

The thing that makes this scale is how we deploy it.

We publish the chart to GitHub Container Registry as an OCI artifact. The chart is versioned. A service pins the version it uses. Config values live in a separate Git repository, always at HEAD.

A single ArgoCD Application looks like this:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: service-maintenance-stage
  namespace: argocd
spec:
  project: staging
  sources:
    # Source 1: versioned chart from OCI registry
    - repoURL: ghcr.io/platform-team
      chart: platform-backend
      targetRevision: 1.2.0
      helm:
        releaseName: service-maintenance-stage
        valueFiles:
          - $values/applications/staging/service-maintenance/values-STAGE.yaml
        parameters:
          - name: nameOverride
            value: service-maintenance
          - name: fullnameOverride
            value: service-maintenance

    # Source 2: config repo at HEAD (ref alias used above)
    - repoURL: https://github.com/platform-team/aks-platform-config.git
      targetRevision: HEAD
      ref: values

  destination:
    name: staging
    namespace: service-maintenance
  syncPolicy:
    automated: {}
    syncOptions:
      - CreateNamespace=true
Enter fullscreen mode Exit fullscreen mode

The $values alias wires the two sources together. The chart comes from the OCI registry at a pinned version. The values file comes from the config repo at whatever HEAD is right now.

This split is the whole game. It means:

  • Config changes (env vars, replica counts, probe paths, secret names) land the moment they're merged to the config repo. No chart release needed.
  • Chart upgrades happen when a team is ready, not when we force them.
  • If we introduce a breaking change in the chart, teams on older versions are unaffected until they choose to migrate.

One Template, Two Scaling Strategies

This is one of the more interesting pieces of the chart. We have a single template file — hpa-keda.yaml — that handles both KEDA event-driven scaling and native Kubernetes HPA. Which one renders depends on a single flag:

# hpa-keda.yaml (simplified)
{{- if .Values.keda.enabled }}

apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: {{ include "app.fullname" . }}
spec:
  podIdentity:
    provider: azure-workload   # no connection strings — uses Workload Identity

---

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: {{ include "app.fullname" . }}
  annotations:
    scaledobject.keda.sh/transfer-hpa-ownership: "true"
spec:
  scaleTargetRef:
    kind: Deployment
    name: {{ include "app.fullname" . }}
  minReplicaCount: {{ .Values.keda.minReplicaCount }}
  maxReplicaCount: {{ .Values.keda.maxReplicaCount }}
  triggers:
    {{- if .Values.keda.trigger.servicebus.enabled }}
    - type: azure-servicebus
      metadata:
        {{- if .Values.keda.trigger.servicebus.topicName }}
        topicName: {{ .Values.keda.trigger.servicebus.topicName }}
        subscriptionName: {{ .Values.keda.trigger.servicebus.subscriptionName }}
        {{- else }}
        queueName: {{ .Values.keda.trigger.servicebus.queueName }}
        {{- end }}
        namespace: {{ .Values.keda.trigger.servicebus.namespace }}
        messageCount: {{ .Values.keda.trigger.servicebus.messageCount | quote }}
      authenticationRef:
        name: {{ include "app.fullname" . }}
    {{- end }}
    {{- range .Values.keda.trigger.eventhubs }}
    - type: azure-eventhub
      metadata:
        eventHubNamespace: {{ .eventHubNamespace }}
        eventHubName: {{ .eventHubName }}
        storageAccountName: {{ .storageAccountName }}
        blobContainer: {{ .blobContainer }}
        consumerGroup: {{ .consumerGroup | default "$Default" }}
        unprocessedEventThreshold: {{ .unprocessedEventThreshold | default "64" | quote }}
      authenticationRef:
        name: {{ include "app.fullname" $ }}
    {{- end }}

{{- else if .Values.autoscaling.enabled }}

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "app.fullname" . }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "app.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}

{{- end }}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

No connection strings in KEDA. The TriggerAuthentication uses provider: azure-workload — the KEDA operator authenticates to Azure Service Bus and Event Hubs using the pod's federated identity token. Zero secrets stored anywhere. This also means the scaledobject.keda.sh/transfer-hpa-ownership: "true" annotation is important: when KEDA creates its own HPA internally, this tells it to take ownership of any pre-existing HPA rather than conflicting with it.

KEDA and HPA are mutually exclusive at the template level. You can't accidentally have both. The if/else if structure enforces it.

KEDA can optionally add CPU/memory triggers alongside queue triggers. A worker might scale primarily on Service Bus message count but also scale up on CPU if the queue is empty but pods are hot.


DB Migrations as a First-Class Deployment Concern

This is the piece that most teams implement ad-hoc and later regret.

The ArgoCD PreSync hook runs a Kubernetes Job before the deployment rollout begins. It uses the same container image as the service (so migrations are always paired with the code that needs them), inherits the same env vars and secret mounts, and runs with the same service account and Workload Identity:

# argo-presync-hook.yaml
{{- if .Values.argoHooks.dbMigration.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ $fullName }}-db-migration
  annotations:
    argocd.argoproj.io/hook: "PreSync"
    argocd.argoproj.io/hook-delete-policy: "HookSucceeded,BeforeHookCreation"
    argocd.argoproj.io/sync-wave: "10"
spec:
  ttlSecondsAfterFinished: 900
  template:
    spec:
      serviceAccountName: {{ include "app.serviceAccountName" . }}
      containers:
      - name: db-migrations
        image: "{{ $image }}:{{ $imageTag }}"
        command:
          {{- range .Values.argoHooks.dbMigration.command }}
          - {{ . | quote }}
          {{- end }}
        env:
          {{- range .Values.deployment.environment }}
          - name: {{ .name }}
            value: {{ .value | quote }}
          {{- end }}
      restartPolicy: Never
  backoffLimit: 0
{{- end }}
Enter fullscreen mode Exit fullscreen mode

The HookSucceeded,BeforeHookCreation delete policy means: clean up the Job after it succeeds, and if a new sync starts before the old Job is cleaned up, replace it rather than block. ttlSecondsAfterFinished: 900 is the backstop — if ArgoCD misses the cleanup, the Job self-destructs after 15 minutes.

If migrations fail, the deployment does not proceed. This is not the default Kubernetes behaviour, and it's not something most teams set up correctly on their own.

To enable it for a service:

# service values file
argoHooks:
  dbMigration:
    enabled: true
    command:
      - "/app/migrate.sh"
Enter fullscreen mode Exit fullscreen mode

The Breaking Change That Wasn't

In v1.0.0 of the chart, we changed the default ingress class from nginx to kong. This was a BREAKING CHANGE — it's called out in the changelog in all caps.

Here's what actually happened when we released it:

Nothing, for most services. Because they were still on v0.x.x.

Services migrated to v1.0.0 when their team was ready, tested in staging first, and bumped the targetRevision in their ArgoCD Application. They saw the ingress change, updated their Kong-specific annotations, and moved on. The whole process took maybe 30 minutes per service.

If we had been using a shared chart without versioning — something like a Helm repository that all services track at latest — that breaking change would have been a coordination nightmare. Every team would have needed to change their configuration at the same time. Someone would have missed it. An incident would have followed.

Semantic versioning works. OCI publishing enforces it. The multi-source ArgoCD pattern makes it painless.


What a Service Values File Actually Looks Like

After all of this, what does a team actually write? Here's a real production values file (names changed):

replicaCount: 1
rollme: "v1.5.0"

nameOverride: "service-conditionplatform-worker"
fullnameOverride: "service-conditionplatform-worker"

image:
  tag: "v1.5.0"

containers:
  - name: service-conditionplatform-worker
    image: "registry.azurecr.io/service-conditionplatform-worker"
    imagePullPolicy: IfNotPresent
    securityContext:
      capabilities:
        drop:
          - ALL
      runAsNonRoot: true
      runAsUser: 10001
      runAsGroup: 2000
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false
    resources:
      limits:
        cpu: 500m
        memory: 256Mi
      requests:
        cpu: 100m
        memory: 150Mi
    volumeMounts:
      - name: tmp
        mountPath: /tmp

volumes:
  - name: tmp
    emptyDir: {}

serviceAccount:
  create: false
  name: "service-conditionplatform"

deployment:
  environment:
    - name: ASPNETCORE_ENVIRONMENT
      value: Prod-Cloud
    - name: APP_LOG_LEVEL
      value: Warning

service:
  enabled: false

ingress:
  enabled: false

autoscaling:
  enabled: false

podAntiAffinity:
  enabled: true
  topologyKey: "kubernetes.io/hostname"
Enter fullscreen mode Exit fullscreen mode

That's it. The team wrote about 40 lines of YAML. They own replica count, resource limits, env vars, probe config, and security context. They don't think about Workload Identity setup, rolling update strategy, or migration hooks — those are already handled.

Onboarding a new service is: copy an existing values file, change the name, image, and serviceAccount fields. Five minutes.

We have 119 production values files structured this way.


The Publishing Pipeline

The chart CI is a reusable GitHub Actions workflow gated by a publish boolean:

# reusable-publish-helm-chart.yaml (simplified)
jobs:
  upload_and_lint:
    steps:
      - uses: azure/setup-helm@v4
      - name: Helm lint
        run: helm lint .
      - name: Helm template
        run: helm template .
      - name: Package
        run: helm package . --destination .
      - name: Upload artifact
        uses: actions/upload-artifact@v6

  publish:
    needs: upload_and_lint
    if: ${{ inputs.publish == true }}
    steps:
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Push to GHCR
        run: helm push *.tgz oci://ghcr.io/platform-team
Enter fullscreen mode Exit fullscreen mode

Every PR lints and templates the chart. Only an explicit release triggers the push. This means the chart artifact in the registry is always a deliberate, tested release — not something that slipped through on a merge.


The Real Lesson

We spent time thinking about Helm chart structure, but the real insight is about platform design.

If you want security contexts to be correct on every service, you cannot rely on every team remembering to set them. If you want Workload Identity on every pod, you cannot make it a configuration option that teams can forget. If you want migrations to run before deployments, you cannot add it to a runbook.

The platform has to make those things happen automatically. The chart is the mechanism. But the mechanism only works if you're clear about what the platform owns versus what service teams own.

Platform owns: Security posture, identity, anti-affinity, deployment strategy, migration orchestration.

Service teams own: Replica counts, resource limits, env vars, probe paths, what gets deployed and when.

Once that line is clear, the chart almost writes itself. And once the chart is written, the hard problems — drift, inconsistency, "we fixed it in six out of eight charts" — go away. Not because the team got more diligent. Because the structure made inconsistency impossible.

Diligence is not a policy. Structure is.


If you're building something similar or have a different approach to Helm at scale, I'd be interested to hear it in the comments.

#kubernetes #helm #gitops #devops #platformengineering

Top comments (0)