Two environments, one ApplicationSet

Most GitOps setups I inherit look the same: one ArgoCD application per environment, a values file per environment, and a production deployment that happens the moment someone merges to the right branch. The pipeline is honest about what it does. It is just not honest about what a mistake costs.

On my own platform I wanted three things at once: one definition of the deployment, a development environment that moves on its own, and a production environment that cannot change without a human deciding to change it.

One ApplicationSet, two environments

The whole delivery is a single ApplicationSet with a list generator:

generators:
  - list:
      elements:
        - env: development
          namespace: interview-challenger-development
          valuesFile: envs/development/values.yaml
        - env: production
          namespace: interview-challenger-production
          valuesFile: envs/production/values.yaml

Each element produces an application name, a namespace, and the values file to apply. Adding an environment is one list entry, not a new manifest to copy and drift.

The chart lives in the application repository, next to the code it deploys. The values live in a separate configuration repository, because they change for reasons the code does not: a new image tag, a replica count, a feature flag.

That split needs a multi-source application:

sources:
  - repoURL: https://github.com/…/interview-challenger.git
    targetRevision: main
    path: deploy/charts/interview-challenger
    helm:
      valueFiles:
        - $configrepo/envs/development/values.yaml
  - repoURL: https://github.com/…/interview-challenger-gitops.git
    targetRevision: main
    ref: configrepo

ref: configrepo is the part people miss. Without it, the chart cannot read values from another repository, and the two repos end up back in one.

Where the gate lives

Both applications auto-sync, with pruning and self-healing on. That sounds irresponsible for production, until you look at where the approval sits.

Production is not protected by stopping the sync. It is protected because the only thing that changes production is a pull request on the configuration repository, and that pull request needs a human to merge it. Once merged, ArgoCD does what it is good at: converging the cluster to the file.

I prefer this to manual sync in ArgoCD, and I would argue the case against it:

  • Manual sync hides drift. A cluster that has not synced looks identical to a cluster that is up to date, in every dashboard you own.
  • Manual sync moves the gate into a tool that is not reviewable. An approval buried in a UI leaves no diff, no author, no reason.
  • A merge request has all three. Who, what, why — and it can be reverted.

The rule I follow: gate the change, not the mechanism that applies it.

What the pipeline does before the PR exists

The release workflow builds the images, tags them with the commit SHA — never latest — pushes to the registry, and then opens the deploy pull requests itself. Development is set to auto-merge. Production is opened and left waiting.

Nothing is deployed by a human typing a tag into a form. The tag is a fact about the code, and the pull request is the only place where the decision happens.

When this is too much

Two environments, one chart and a config repo is roughly the right size for a product with real users and one team. It is too much for a side project that deploys once a month: there, a single docker compose up and a backup are more honest.

The pattern is worth copying when three conditions hold: more than one environment, more than one person able to merge, and a cost to being wrong in production. If one of those is false, the ceremony buys nothing.

← All posts