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
-
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: 500mThe folder name must match the namespace it serves: the
varsKustomization reconciles./vars/${TENANT_NAMESPACE}. A missing folder for a namespace shows up askustomization path not found: ... vars/<namespace>: no such file or directoryinflux get all. -
<app>/ks.yamlwires the substitution in viapostBuild.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 ... -
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-appsKustomization patches every child Kustomization at creation time: it injectsserviceAccountName,targetNamespace, and SOPS decryption, and it managespostBuild.substituteFrom:
- a Kustomization without the label
substitution.flux.use.json/enabled: "true"gets itssubstituteFromreplaced by the platformtenant-settingsConfigMap — your own settings map would be ignored;- a Kustomization with the label keeps its own
substituteFrom, andtenant-settings(which providesTENANT_NAMESPACE,TENANT_DOMAIN,TECHNICAL_ACCOUNT, cluster domains, ...) is appended to it. The label therefore requires the Kustomization to already define its ownsubstituteFrom.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