# flux2-kustomize-helm-example: one chart, two clusters, different values

> This Flux example manages a staging and a production cluster from a single Git repository, keeping the shared parts in apps/base and the per-cluster differences in Kustomize patches under apps/staging and apps/production. It is Apache-2.0 licensed, the last push was on 2026-07-08, and the point of it is as much the directory layout as the automation.

**fluxcd/flux2-kustomize-helm-example** — A GitOps workflow example for multi-env deployments with Flux, Kustomize and Helm.

- Repository: https://github.com/fluxcd/flux2-kustomize-helm-example
- Website: https://fluxcd.io
- Stars: 1,300 · Forks: 1,202
- Language: Shell
- License: Apache-2.0
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/fluxcd-flux2-kustomize-helm-example

## The whole point is one shared base and two thin overlays

The scenario the example sets up is deliberately small: two clusters, staging and production, managed with Flux and Kustomize while minimising duplicated declarations. That phrase, minimising duplicated declarations, is the design goal the rest of the repository serves.

The Git repository is split into three top-level directories. `apps` holds the Helm releases with a custom configuration per cluster, `infrastructure` holds common infrastructure tools such as Envoy Gateway and cert-manager, and `clusters` holds the Flux configuration per cluster. The tree is laid out as:

```bash
├── apps
│   ├── base
│   ├── production 
│   └── staging
├── infrastructure
│   ├── configs
│   └── controllers
└── clusters
    ├── production
    └── staging
```

The applications split follows the same logic one level down. `apps/base/` contains namespaces and Helm release definitions, and `apps/production/` and `apps/staging/` contain only the values for that environment. The base directory for the demo application, `apps/base/podinfo/`, holds a `kustomization.yaml`, a `namespace.yaml`, a `release.yaml` and a `repository.yaml`.

The result is that a change to the application's shape is made once in `apps/base` and reaches both clusters, while a change to a hostname or a chart version is made in one environment's overlay and reaches only that environment. That is the whole idea in one sentence, and the rest of the example is the implementation of it.

## A semver range per environment is how staging and production diverge

The cleverest detail in the example is that the difference between the two environments is not only values, it is the version constraint on the chart itself.

The shared `HelmRelease` in `apps/base/podinfo/release.yaml` pins the chart through a `sourceRef` pointing at a `HelmRepository` named `podinfo` in the `flux-system` namespace, and sets an `interval` of 50m. It does not set a chart version at all, so the base leaves that open.

The staging overlay then sets `version: ">=1.0.0-alpha"`, and the documentation explains what that buys: Flux will automatically upgrade the HelmRelease to the latest chart version including alpha, beta and pre-releases. The production overlay sets `version: ">=1.0.0"`, which means the latest stable chart version, with alpha, beta and pre-releases ignored.

That is a two-line difference in the manifests and it gives you a pre-production cluster tracking the bleeding edge while production tracks only stable releases, both without anybody running a helm upgrade by hand. Flux is doing the upgrade because it reconciles the release against the declared semver range on the interval, and the range is what you change.

The staging overlay also turns on the release's test, with `test: enable: true`, and production does not. So the same chart gets exercised by a Helm test hook in the environment where a broken version is cheap to accept.

## Flux installs its own infrastructure with OCIRepository sources

The `infrastructure` directory shows how Flux bootstraps the tools it needs to do its job, and the mechanism is worth understanding because it is the part that surprises people.

`infrastructure/controllers/` contains namespaces and Helm release definitions for the controllers, and `infrastructure/configs/` contains Kubernetes custom resources such as cert issuers and network policies. The controller definitions use an `OCIRepository` source rather than a `HelmRepository`, pointing at an OCI registry URL:

```yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
  name: cert-manager
  namespace: cert-manager
spec:
  interval: 24h
  url: oci://quay.io/jetstack/charts/cert-manager
```

The `layerSelector` with the Helm chart content media type and a `copy` operation tells Flux how to pull the chart out of the OCI artifact, and the `ref` with `semver: "1.x"` is the version constraint. The `HelmRelease` then references that source through a `chartRef` of kind `OCIRepository`.

Flux checks the OCI repository every 24 hours, and if a newer chart matching the `1.x` constraint is found, it upgrades the release. So the same reconcile loop that manages the demo application also manages the cluster's own certificate and gateway infrastructure, with the source being an OCI registry rather than a chart repository.

The `configs` directory holds the custom resources themselves, and the example's Let's Encrypt `ClusterIssuer` is a good one to look at, because it uses an http01 solver routed through the Gateway API with a `parentRefs` entry pointing at the Envoy gateway.

## Patching the Let's Encrypt server is a Kustomize replace op

The one place where the two environments need a genuinely different value, the ACME endpoint, is handled with a Kustomize patch rather than by duplicating the resource.

The base configuration in `infrastructure/configs/cluster-issuers.yaml` points at the Let's Encrypt staging server, `https://acme-staging-v02.api.letsencrypt.org/directory`, and uses a placeholder email address with the comment asking you to replace it with your own contact email. The solver is an http01 challenge solved through a `gatewayHTTPRoute`, with a `parentRefs` entry naming the `envoy` gateway in the `envoy-gateway-system` namespace with kind `Gateway`.

Then in `clusters/production/infrastructure.yaml` a Kustomization resource applies a patch that replaces the server value with the production endpoint, `https://acme-v02.api.letsencrypt.org/directory`. The patch is a JSON patch operation, `op: replace` on the path `/spec/acme/server`, targeted at the ClusterIssuer, and the resource also declares a `dependsOn` entry naming `infra-controllers`, so the controllers are installed before the configs that depend on them.

This is the pattern to take away. The base holds the resource with a safe default, and each cluster's Kustomization applies the minimal difference. The alternative, keeping two copies of the ClusterIssuer, is exactly the duplication the example is trying to eliminate, and it is how a shared file ends up diverging between environments in a way nobody notices until a certificate fails in one of them.

## Prerequisites are a 1.33 cluster and a GitHub token that can create repositories

The setup requirements are short and one of them is easy to miss.

You need a Kubernetes cluster of version 1.33 or newer. For a quick local test, the guide points at Kubernetes kind, and any other Kubernetes setup will work as well. The GitOps model needs somewhere to put the Git repository, and the guide assumes a GitHub account and a personal access token that can create repositories, with all permissions under `repo` checked. That last requirement is a real constraint: the token needs write access to create the repository that Flux will then reconcile from.

The Flux CLI is installed with Homebrew on macOS or Linux:

```bash
brew install fluxcd/tap/flux
```

or by running the install script:

```bash
curl -s https://fluxcd.io/install.sh | sudo bash
```

The rest of the deployment procedure lives in the `scripts/` directory rather than in the README, and the repository also has a `.fluxschema.yml` file, which is the schema validation configuration for Flux custom resources, and a `.sourceignore` file.

The CI setup is worth noticing, because an example repository that only works locally is much less useful. There are two workflow badges, one for `test` and one for `e2e`, so the repository validates its own manifests and runs an end-to-end check against the deployment it describes. For a project whose entire content is manifests, having the manifests validated in CI is the difference between an example and a template.

## Where this example is the wrong thing to copy

The clearest case is a platform that needs more than Helm charts. This repository shows Helm releases, Kustomize overlays and Flux sources, and nothing else. It ships no application code and no build pipeline that produces a container image. A platform that deploys operators or runs a build-and-push step for its own services will need Flux's other APIs, and the structure here will need to grow well beyond what the example shows.

The second case is environment drift that has nothing to do with values. The example handles the difference between the two clusters with patches to a small number of fields. In a real estate with a dozen environments, per-environment differences accumulate and the base-plus-overlay model starts to strain, because every new environment needs a new overlay directory and the overlays accumulate conditional patches. Flux supports a different strategy for that, and this example does not show it.

The third case is a team that wants the automation without the learning material. If you already know Flux and Kustomize, the value of this repository drops to the directory layout, and the layout is a reasonable starting point but not a design. If you are starting from zero, the example is valuable precisely because it shows the pieces in a small, complete configuration, and that is the audience it is written for.

A last note on the state of the repository. There are no GitHub releases, which is normal for an example that is tracked on its default branch, and the last push was on 2026-07-08, with the Flux version referenced in the manifests at `helm.toolkit.fluxcd.io/v2` for HelmRelease and `source.toolkit.fluxcd.io/v1` for OCIRepository. Those API versions are the thing to check before copying the manifests into a cluster running a different Flux release.

## Conclusion

This repository fits an engineer who wants to understand how Flux, Kustomize and Helm compose, because the whole thing is small enough to read in one sitting and every piece is exercised twice, once per cluster. It does not fit a production platform team who needs more than Helm, since it ships no application code or container image build pipeline. Before adapting it, read the `apps/base/podinfo/release.yaml` file and the two `podinfo-values.yaml` files, because the entire difference between the environments lives in those three files.

## FAQ

### How does the flux2-kustomize-helm-example keep staging and production separate?

The shared HelmRelease and namespaces live in `apps/base/`, and each environment has an overlay directory, `apps/staging/` and `apps/production/`, containing a Kustomize patch with the values and the chart version range for that cluster.

### What does the version range in the podinfo release do?

The staging overlay sets `version: ">=1.0.0-alpha"` so Flux upgrades to the latest chart version including alpha, beta and pre-releases, while the production overlay sets `version: ">=1.0.0"` so only stable versions are taken. The base release sets no version at all.

### How do I install the Flux CLI for this example?

With Homebrew using `brew install fluxcd/tap/flux`, or with the install script, `curl -s https://fluxcd.io/install.sh | sudo bash`. You also need a Kubernetes cluster of version 1.33 or newer and a GitHub personal access token that can create repositories.

### How are the cluster differences applied in the infrastructure configuration?

The base `ClusterIssuer` in `infrastructure/configs/cluster-issuers.yaml` points at the Let's Encrypt staging server, and the production cluster's Kustomization in `clusters/production/infrastructure.yaml` applies a patch that replaces `/spec/acme/server` with the production endpoint.

## Sources

- [fluxcd/flux2-kustomize-helm-example on GitHub](https://github.com/fluxcd/flux2-kustomize-helm-example)
- [Issues](https://github.com/fluxcd/flux2-kustomize-helm-example/issues)
- [License: Apache-2.0](https://github.com/fluxcd/flux2-kustomize-helm-example/blob/main/LICENSE)
- [Project website](https://fluxcd.io)
- [README](https://github.com/fluxcd/flux2-kustomize-helm-example/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/fluxcd-flux2-kustomize-helm-example
