5 Guide Repository Handling Multiple Environments
Angel Nunez Mencias edited this page 2026-09-27 23:22:27 +02:00

GitOps-Ready Namespaces with Flux

Overview

In our OpenShift clusters, namespaces are provisioned GitOps-ready by default. Each namespace is linked to a Git repository where configurations are stored and managed declaratively.

This setup enables:

  • Consistent GitOps workflow across tenants
  • Multi-environment support from a single repo
  • One shared copy of every manifest — environment differences live in per-namespace values, not in overlays

A namespace linked to a git repository is what we call a Tenant. For this example we use 3 environments: dev, staging, prod.

We forward the TENANT_NAMESPACE variable holding the value of your tenant name. This allows Kustomizations to dynamically target the right folder for each environment.

Flux Kustomization e.g.

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: my-app
  namespace: ${TENANT_NAMESPACE}
spec:
  targetNamespace: ${TENANT_NAMESPACE}
  interval: 5m
  path: ./my-app/app
  prune: true
  sourceRef:
    kind: GitRepository
    name: tenant-repos

Per-environment values with variables (no overlays)

Any value that differs between environments (replicas, resources, storage size, URLs, ...) is declared once in the manifests as a ${VARIABLE} and is filled in by Flux from a per-namespace ConfigMap under vars/<namespace>/. There is no base//overlays/ tree in the applications directory — just one copy of the manifests under <app>/app/.

Given the vanilla tenant repository:

.
├── config
│   ├── app
│   │   └── limits-ranges.yaml
│   └── ks.yaml
├── echo-server
│   ├── app
│   │   └── helmrelease.yaml    # Flux auto-generates kustomization.yaml here
│   └── ks.yaml
├── kustomization.yaml
├── README.md
├── renovate.json5
├── repos
│   ├── helm
│   │   └── bjw-s.yaml
│   └── ks.yaml
├── scripts
│   └── rewrap-secrets.sh
└── vars
    ├── ks.yaml
    ├── tenant-tpl
    │   ├── .sops.yaml
    │   ├── echo-server-settings.yaml
    │   └── README.md
    └── tenant-tpl-dev
        └── echo-server-settings.yaml

How it works

  1. vars/<namespace>/echo-server-settings.yaml — one ConfigMap per app per namespace holding only the values that change between environments:

    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: echo-server-settings
    data:
      ECHO_SERVER_REPLICAS: "3"   # numbers are quoted: substitution is textual
      ECHO_SERVER_CPU_LIMIT: 500m
    

    The folder name must match the namespace it serves: the vars Kustomization reconciles ./vars/${TENANT_NAMESPACE}. A missing folder for a namespace shows up as kustomization path not found: ... vars/<namespace>: no such file or directory in flux get all.

  2. <app>/ks.yaml wires the substitution in via postBuild.substituteFrom:

    metadata:
      name: &app echo-server
      namespace: ${TENANT_NAMESPACE}
      labels:
        substitution.flux.use.json/enabled: "true"   # see gotcha below
    spec:
      path: ./echo-server/app
      dependsOn:
        - name: vars
      postBuild:
        substituteFrom:
          - kind: ConfigMap
            name: echo-server-settings
      ...
    
  3. The manifests reference the variables — one shared copy under echo-server/app/:

    values:
      controllers:
        echo-server:
          replicas: ${ECHO_SERVER_REPLICAS}
          containers:
            app:
              resources:
                limits:
                  cpu: ${ECHO_SERVER_CPU_LIMIT}
    

⚠ Platform gotcha (substitution label)

The bootstrap tenant-apps Kustomization patches every child Kustomization at creation time: it injects serviceAccountName, targetNamespace, and SOPS decryption, and it manages postBuild.substituteFrom:

  • a Kustomization without the label substitution.flux.use.json/enabled: "true" gets its substituteFrom replaced by the platform tenant-settings ConfigMap — your own settings map would be ignored;
  • a Kustomization with the label keeps its own substituteFrom, and tenant-settings (which provides TENANT_NAMESPACE, TENANT_DOMAIN, TECHNICAL_ACCOUNT, cluster domains, ...) is appended to it. The label therefore requires the Kustomization to already define its own substituteFrom.

Rule of thumb: exactly the Kustomizations that consume variables from your own vars/<namespace>/ ConfigMaps carry the label.

Adding a new environment

Adding a namespace to an existing repository needs no change to the application manifests:

cp -r vars/tenant-tpl vars/my-tenant-staging   # name it after the namespace
vim vars/my-tenant-staging/echo-server-settings.yaml   # adjust the values
git commit -am 'Add vars for my-tenant-staging' && git push

Verification:

$ flux reconcile ks vars -n my-tenant-staging --with-source
$ flux get all -n my-tenant-staging
$ oc get configmap -n my-tenant-staging
NAME                       DATA   AGE
echo-server-settings       2      3m32s
kube-root-ca.crt           1      36m
openshift-service-ca.crt   1      36m
tenant-settings            10     36m

Checking which values are live

# what Flux reconciles where
$ flux get all -n tenant-tpl-dev

# full ownership chain of a workload
$ flux trace hr echo-server -n tenant-tpl-dev

# effective value on the running workload (dev: 2 replicas, 100m CPU limit)
$ oc get pods -n tenant-tpl-dev
$ oc get deploy echo-server -n tenant-tpl-dev -o jsonpath='{.spec.template.spec.containers[0].resources.limits.cpu}'

The same manifests render differently per environment — dev runs "2" replicas / 100m CPU limit from vars/tenant-tpl-dev/, while tenant-tpl runs "3" replicas / 500m — with zero duplicated manifests.

Find the file references

App manifests (shared)

(Flux generates the kustomization.yaml for ./echo-server/app automatically — no file needed in git.)

Main Kustomization (substitution wiring)

Per-namespace values