DEV Community

Cover image for GitOps with Helm for small teams: what
Billy Walker for Core Solutions

Posted on Originally published at coresolutions.ltd

GitOps with Helm for small teams: what

Most small teams arrive at GitOps from the same place. Every service might already have a Helm chart, but production is whatever someone last ran helm upgrade with from their laptop, plus the --set replicaCount=4 somebody added during last month's incident, plus a values file that only exists in one engineer's home directory. Then they go looking for help and find a wall of machinery: app-of-apps layouts, promotion pipelines, multi-cluster generators, progressive delivery. If you are a team of three trying to stop deploys happening from somebody's laptop, that is not a realistic starting point.

For small teams, the part of GitOps that pays off immediately is the reconciliation loop. Most of the machinery built around it can wait.

Strip GitOps back to first principles and it is genuinely useful. The OpenGitOps principles give you four: desired state is declarative, versioned and immutable, pulled automatically, and continuously reconciled. Those get you reviewed change, a real audit trail, drift detection and rollback by git revert, and none of them needs an elaborate delivery platform.

If you already use Helm, most of the pieces are in place. What you need on top is small: charts and values files in Git, one reconciler to apply them, a values layout that keeps environments honest, and a secrets plan that is not reckless. This post takes each in turn, then covers the machinery you can leave alone and the sharp edges you will meet once the loop is running.

The useful bit of GitOps is boring in the best way

Once a tool such as Argo CD or Flux is watching a repository and rendering your charts from it, a few things improve straight away:

  • Change becomes reviewable. Every deploy is a commit to a chart or a values file, and usually a pull request.
  • Rollback gets simpler. In the happy path it is a git revert rather than a reconstruction job.
  • Drift becomes visible. When someone changes a live object out of band, the reconciler notices.
  • Cluster state becomes reproducible. You stop relying on memory and shell history to explain which values produced what is running.

That rollback point changes one Helm habit. Under GitOps, helm rollback stops being the tool for the job. On Argo CD there is no Helm release to roll back, and on Flux a manual rollback leaves the release out of step with what Git declares, so the controller upgrades it straight back. Revert the commit instead and let the loop do the deploy.

All of this is a meaningful operational upgrade at any size, and it lines up with DORA's research. Its continuous delivery capability lists version control for all production artefacts, and deployment automation, among the practices that drive continuous delivery, which in turn improves delivery performance. I would be careful not to overclaim here. DORA does not measure GitOps as a separate intervention with a tidy uplift figure. What it does back is the discipline GitOps happens to enforce well.

Start with the loop

If I were introducing GitOps to a team that already ships Helm charts, I would start with a setup that feels almost underwhelming:

  • charts and their values in Git, with environment differences as values files on one branch
  • one reconciler running in the cluster
  • pull requests as the only normal path into production
  • chart versions pinned, so the same commit always renders the same manifests
  • automated sync switched on, with pruning and self-heal treated as things the team earns

That last point deserves a closer look, because the two main tools handle it differently. Self-healing sounds great until an urgent out-of-band fix gets reverted mid-incident because Git still says otherwise. The controller is doing what you asked, not what you meant five minutes into an outage.

Argo CD leaves both sharp edges off by default. An Application with automated sync and nothing else set behaves like this:

syncPolicy:
  automated:
    prune: false
    selfHeal: false
Enter fullscreen mode Exit fullscreen mode

With those settings, Argo CD syncs when a new commit lands. It does not delete resources that disappear from Git, and it does not revert a live change on its own: the drift shows up as OutOfSync for a human to deal with. Turn on selfHeal and prune once the team trusts the loop and, more to the point, once everyone has stopped fixing production by hand.

Flux splits the same decision across its two controllers, and the split catches Helm users out. A plain Kustomization re-applies what Git says on every interval and corrects any drift it finds, with no switch to turn that off. A HelmRelease is different: drift detection is opt-in, so by default a kubectl edit against a Helm-managed Deployment stays put until the next chart or values change triggers an upgrade. You choose the behaviour explicitly:

spec:
  driftDetection:
    mode: warn # report drift as an event; change to `enabled` to correct it
Enter fullscreen mode Exit fullscreen mode

warn is the Flux equivalent of Argo CD with self-heal off, and a sensible place to start. The section on running the loop comes back to what to do when you need the reconciler to back off.

Argo CD vs Flux: pick by how your team works

People love turning this into theology. It doesn't need to be.

Flux and Argo, the project that includes Argo CD, reached CNCF Graduated status within a week of each other in late 2022. Either can be the right answer, and neither is a toy. The first difference is how people expect to interact with deployments.

Argo CD suits teams that want an app-centric control plane with a strong UI. You declare Application resources and get a visual model of sync status, health and drift, so people can see what is going on without living in the CLI.

Flux suits teams that prefer a composable set of controllers and an experience shaped around Git, the CLI and the Kubernetes API. It feels closer to assembling the controllers you need than to logging into a deployment console.

The second difference is specific to Helm, and it is worth knowing before you pick. Argo CD uses Helm only as a template engine: it runs helm template and applies the rendered manifests itself, so helm list shows nothing and there is no Helm release history in the cluster. Flux's helm-controller performs real Helm installs and upgrades, so releases, history and helm list all behave as they did before GitOps, and failed upgrades can be remediated with Helm's own rollback. Neither approach is wrong. If your team leans on Helm tooling and release history, Flux will feel familiar; if you would rather Helm stayed a rendering step and the Argo CD UI became the source of truth for what is deployed, Argo CD's model is simpler to reason about.

So my heuristic is mostly a social one:

  • pick Argo CD if people routinely want to look at deploy state and reason about it in one place
  • pick Flux if your team is happy in Git and the terminal, wants real Helm releases, and would rather compose small controllers than run a dashboard

The secrets section below adds one more technical tie-breaker. If you want to go further with Argo, we have a hands-on post on GitOps for Kubernetes with Argo CD.

One repo per service, chart included

Repository debates get oddly ideological. Keep it plain: each service is its own repo, and its Helm chart lives next to the code it deploys. The chart then versions with the application, so a change that needs a new environment variable and the code that reads it lands in one pull request.

payments-api/
├── src/
├── Dockerfile
└── chart/
    ├── Chart.yaml
    ├── values.yaml          # base: what every environment shares
    ├── values/
    │   ├── staging.yaml     # only what differs in staging
    │   └── production.yaml  # only what differs in production
    └── templates/
Enter fullscreen mode Exit fullscreen mode

The layout mirrors how Helm already merges values: a base that every environment shares, and a thin file per environment that only says what differs. In Argo CD, each environment's Application points at the service repo's chart/ directory and names its environment file. The chart's own values.yaml is always the base, and anything in valueFiles is layered on top of it:

source:
  repoURL: https://github.com/example/payments-api.git
  targetRevision: v1.4.2
  path: chart
  helm:
    valueFiles:
      - values/production.yaml
Enter fullscreen mode Exit fullscreen mode

Flux does the same with a HelmRelease whose chart comes from the service's GitRepository. Its valuesFiles replace the chart's default values and merge in order, with paths relative to the repo root, so list the base first:

spec:
  chart:
    spec:
      chart: ./chart
      sourceRef:
        kind: GitRepository
        name: payments-api
      valuesFiles:
        - ./chart/values.yaml
        - ./chart/values/production.yaml
Enter fullscreen mode Exit fullscreen mode

Those Application and HelmRelease definitions are the only thing that isn't per service: a small deployments repo holding one per service and environment, which is also where the reconciler reads what runs where.

Argo CD's best-practices guide recommends keeping config in a separate repo from application source, and its reasons are worth handling rather than ignoring. A values-only change shouldn't trigger a full CI build, so path-filter the pipeline. CI shouldn't commit image tags back into the repo it builds from, or you get a build loop. And a code commit shouldn't reach production on its own, which is what the pinned targetRevision above is for: staging tracks main, production pins a release, and promotion is a reviewed pull request that bumps the pin.

A few opinions, stated plainly:

  • Use values files for environments, not branches. Branch-per-environment sounds tidy until promotion turns into cherry-picking and drift archaeology.
  • Keep environment files thin. If production.yaml is most of a copy of values.yaml, the base isn't doing its job, and the next shared change will land in one file and not the other.
  • Pin what production runs. Pin the service's targetRevision to a tag or commit, and pin upstream chart versions exactly rather than to a range such as 6.5.*. Let a bot such as Renovate raise the bumps as pull requests. A range means the same commit can render different manifests next week.

One Helm behaviour bites almost everyone who adopts this layout. Helm merges maps deeply, but it replaces lists outright: "scalar values and arrays are replaced, maps are merged". So a production.yaml that adds one environment variable to an env: list doesn't add it; it replaces every entry the base defined, and the base's variables silently vanish from production. Keep list-valued settings in one file, or have the chart accept a map and render the list from it.

Shared infrastructure follows the same rule: cert-manager, ingress and External Secrets are each their own chart, deployed before the services that use them. Your charts will create custom resources, such as a cert-manager Certificate or an ExternalSecret, and those only work once the CRDs exist and the controller behind them is actually running. It is also a Helm limitation: Helm never upgrades or deletes CRDs from a chart's crds/ directory, and its own docs suggest a separate chart for them. Flux handles the ordering with dependsOn, so a service's release waits until the infrastructure it needs reports ready. Argo CD uses sync waves, set with the argocd.argoproj.io/sync-wave annotation, which order resources within a single Application. Ordering whole Applications needs app-of-apps plus a custom health check for the Application kind, and that is one of the few good reasons to adopt app-of-apps early.

Secrets in GitOps: SOPS, Sealed Secrets or External Secrets

Every GitOps conversation eventually arrives at the part the tidy diagrams leave out, and with Helm the first place a secret leaks is a values file. "Just put them in Git" is not a serious answer. Git is durable, replicated, and very good at remembering things you wish it had forgotten.

The usual options:

  • SOPS encrypts the values in the files you commit, so diffs stay reviewable. Flux decrypts SOPS natively through spec.decryption on a Kustomization, and a HelmRelease can read the decrypted Secret as values through valuesFrom. Argo CD has no built-in support, so you add SOPS to its repo server with a plugin. That moves decryption into manifest generation, which Argo CD's own secret-management guide strongly cautions against, because generated manifests sit in plaintext in its Redis cache.
  • Sealed Secrets gives you a SealedSecret that is safe to commit and that only the controller in the target cluster can decrypt. It is easy to explain and a fine early move, but it is cluster-bound. By default the controller renews its sealing key every 30 days and keeps the old ones, so disaster recovery means backing up every secret labelled sealedsecrets.bitnami.com/sealed-secrets-key, and refreshing that backup after each renewal. Lose those keys along with the cluster and every SealedSecret in Git is ciphertext nobody can open.
  • External Secrets Operator keeps references in Git and the values in a real secrets manager such as Vault, AWS Secrets Manager or Azure Key Vault. Your chart templates an ExternalSecret instead of a Secret, and the values file only ever holds the key's path. It is often the cleanest long-term model, and our post on syncing secrets into Kubernetes with External Secrets walks through it. The trade-off is a hard runtime dependency on that external system, and on the operator itself: in 2025 the project paused releases for several weeks over maintainer burnout, until more maintainers joined. Anything on your runtime path deserves a look at who maintains it.

Argo CD's guide recommends populating secrets on the destination cluster, which is what the last two do. Put that together and my bias looks like this:

  1. if you already have a cloud secrets manager, go straight to External Secrets Operator
  2. if you don't and you run Flux, SOPS is the cleanest Git-native option
  3. if you don't and you run Argo CD, start with Sealed Secrets and treat the key backup as part of the install

Picking the "wrong" tool is recoverable. The mistake that lingers is deferring the decision, migrating everything else, and leaving secrets as a loose end to tidy up later. Sort them out alongside the reconciler, because they are part of the same design.

The machinery to skip until it hurts

This is where small teams get talked into complexity they have not earned yet. Plenty of the canonical GitOps add-ons are real solutions, just to later problems:

  • app-of-apps sprawl solves large estates with many related deployments
  • ApplicationSet generators solve repetition across fleets of charts and clusters
  • promotion pipelines solve controlled movement across several environments
  • progressive delivery, with a tool such as Argo Rollouts, solves high-stakes releases where traffic shaping is worth another controller
  • multi-cluster GitOps solves hard isolation, geography, tenancy or blast-radius constraints

If those are not your current pressures, adopting them early is overhead you pay every week for a problem you don't have yet.

It is the same argument as our post on right-sizing platform architecture: do not borrow scale machinery before the scale arrives. GitOps has the trap every fashionable platform idea has. The sensible core gets wrapped in an architecture identity, and teams start buying components to prove they are doing it properly. You are doing GitOps properly if Git is the source of truth and a reconciler is continuously working to make the cluster match it.

Running the loop once it's live

The machinery can wait. These can't, because every team meets them within a few weeks of switching the reconciler on, however small the estate.

Pause the loop before you fix production by hand. During an incident the reconciler is on Git's side, not yours. In Flux, flux suspend helmrelease <name> (or flux suspend kustomization <name> for plain manifests) stops new revisions and drift correction, and the matching flux resume turns them back on. In Argo CD, argocd app set <app> --sync-policy none takes one Application off automated sync. There are two catches on the Argo side: an Application generated by an ApplicationSet ignores changes to its own sync policy, and a parent app with self-heal on can put a child's policy straight back. Whatever you change by hand, commit the same change to the values file before you resume, or the loop will quietly undo your fix. Put those commands in the incident runbook now, while things are calm.

Let the autoscaler own replicas. If a HorizontalPodAutoscaler manages a Deployment and your chart also renders spec.replicas, the two fight, and every sync resets the count the HPA chose. The chart that helm create scaffolds already handles this: its Deployment only renders replicas when autoscaling.enabled is false, so the fix is usually one value rather than a template change. Argo CD's best-practices guide and the Flux FAQ give the same advice: leave replicas out of what you apply. If a third-party chart insists on rendering it, Argo CD's ignoreDifferences on /spec/replicas hides it from the diff, but the sync still applies it unless you also set the RespectIgnoreDifferences=true sync option. On Flux, a HelmRelease takes driftDetection.ignore with paths: ["/spec/replicas"], which its docs suggest for exactly this case.

Preview what a pull request will actually render. With Helm, reading a values diff is not the same as knowing what the manifests will look like, because one value can fan out across a dozen templates. The cheapest check needs no cluster at all: run helm template with the base and environment files on your branch and on main, and diff the two outputs. For the live comparison, argocd app diff <app> --revision <branch> renders the chart and compares it with what is running, with one gap: it leaves Secrets out. flux diff kustomization is less useful here, because for a HelmRelease it shows the change to the HelmRelease object rather than to the rendered manifests. Running a live diff in CI means giving CI read access to the cluster, which is a real trade for a pull-based setup. Running it locally before you open the pull request costs nothing.

Make the loop react faster than it polls. By default Argo CD checks Git every two minutes plus up to a minute of jitter, and Flux fetches each GitRepository on whatever interval you give it. A merged pull request that appears to do nothing for a few minutes is usually waiting for the next poll. Both tools accept webhooks: Argo CD's API server takes Git webhook events directly, and Flux uses a Receiver from its notification controller. Keep polling as the fallback, so a missed webhook costs you minutes rather than a deploy.

Where to start

Pick one low-risk chart, ideally a stateless internal service, and move it to the base-plus-environment layout: values.yaml for what is shared, values/production.yaml for the handful of lines that differ. Put it under the reconciler with automated sync on and pruning off, and on Flux set driftDetection.mode: warn. Make a pull request the only way it changes for a fortnight, and keep a note of every drift the reconciler reports, whether that is Argo CD marking the app OutOfSync or Flux emitting a drift event on the HelmRelease. Every one of those is a hand-edit or a --set someone still reaches for, and each becomes either a runbook entry, a value that belongs in Git, or a field handed over to another controller. When a week goes by with nothing to explain, turn on self-heal and pruning, move the next chart across, and leave the rest of the GitOps catalogue on the shelf until a real problem asks for it.

For a small team, most of the effort in this post goes into standing the loop up rather than using it. That is the part we built Kupe Cloud to take away. It is our managed Kubernetes platform, and both halves of the setup are ready as soon as you spin up your first cluster. Argo CD is already running, with a project set up for your tenant, so once you connect a service repo laid out as above, its chart deploys as it is. Secrets work the same way from day one: you create a secret once in the console, it is stored encrypted in the platform's vault, and Kupe syncs it as a Kubernetes Secret into the clusters and namespaces you choose. That is the External Secrets model from earlier, with nothing extra to install or run. Everything else in this post still applies; you just start with the loop already running.

Top comments (0)