GitOps Tenant Onboarding
Onboarding a tenant — an application that runs on a shared, multi-tenant
Flux cluster from its own repository — follows the same
recurring shape every time. The tenant repo builds a container image and publishes
its Kubernetes manifests as a signed OCI artifact; the platform pulls that
artifact with a Flux OCIRepository + Kustomization and runs it in a dedicated,
locked-down namespace. This skill is the agent-facing companion to that pattern:
the decisions, the conventions, and — most usefully — the gotchas that make a
fresh tenant fail if you miss them.
It is built around an opinionated but industry-standard stack — Flux, External
Secrets Operator backed by OpenBao/Vault,
Kyverno + PodSecurity, Cilium / Gateway
API, cosign — so
the steps transfer to any cluster wired the same way; adapt the resource names to
your platform.
The two halves
Onboarding always spans two repos, and a tenant is not live until both land:
- The tenant repo — created from a tenant template. Ships the shared,
framework-agnostic CI/CD plumbing (build → signed publish → release) plus a
deploy/ directory of Kubernetes manifests you own and customise.
- The platform registration — a small directory in the platform repo
(
apps/<tenant>/) that grants the tenant a namespace, an identity (a
ServiceAccount), RBAC, a network policy, the OpenBao-backed image-pull secret,
and the Flux resources that pull and verify the tenant's artifact.
Open each as a PR. The tenant goes live when the platform registration merges
and Flux reconciles it — the tenant repo alone does nothing until it is registered.
Tenant repo: the deploy/ manifests
A tenant's deploy/ is a Kustomize overlay. The full set, and what each is for:
| Manifest |
Purpose |
Drop it when… |
deployment.yaml |
the workload |
— |
service.yaml |
ClusterIP for the app port |
— |
httproute.yaml |
Gateway API HTTPRoute attaching to the shared gateway |
the app is not HTTP-exposed |
poddisruptionbudget.yaml |
drain-safe maxUnavailable: 1 PDB |
never — every workload needs one |
networkpolicy.yaml |
re-opens ingress/egress under the cluster's default-deny |
never on a default-deny cluster |
secretstore.yaml + externalsecret.yaml |
namespaced External Secrets store + secret |
the tenant needs no app secrets |
cluster.yaml |
CloudNativePG database |
the tenant has no database |
kustomization.yaml |
lists the above |
— |
Conventions and gotchas that bite
These are the failure modes worth memorising — each one produces a tenant that
looks configured but is broken at reconcile, admission, or runtime:
- The container
name MUST equal the repository name. The signed-publish
pipeline pins the freshly built image digest into the container named after the
repo. A mismatch means the running image is never updated. Rename the
placeholder container throughout deployment.yaml.
- Set the
seccompProfile at the pod level, not only the container. A
PodSecurity "restricted" cluster (and a Kyverno require-seccomp-profile rule)
demands securityContext.seccompProfile.type: RuntimeDefault on the pod
spec. Setting it only on the container passes naive review but is rejected at
admission. Set runAsNonRoot: true + seccompProfile on both pod and
container; the container also gets allowPrivilegeEscalation: false,
readOnlyRootFilesystem: true, capabilities.drop: [ALL].
- A default-deny cluster makes a tenant unreachable until it ships an allow
policy. If the platform generates a deny-all network policy in every tenant
namespace, a tenant with no
networkpolicy.yaml cannot receive Gateway traffic
or reach its own database. Ship the allow policy day-one: ingress from the
gateway on the app port, egress to DNS, plus intra-namespace + operator rules if
it has a database. (With Cilium, an empty endpoint selector {} in a
fromEndpoints/toEndpoints rule selects this namespace's own pods — so
intra-namespace rules need no namespace name and stay placeholder-free.)
- Tenant secrets come from the secret store, never SOPS. No tenant ships an
encrypted Secret in git. App secrets are delivered by External Secrets from
OpenBao/Vault (see below). The Flux
Kustomization needs no spec.decryption.
- Use a namespaced
SecretStore, never the shared ClusterSecretStore. A
multi-tenant cluster blocks tenants from referencing the cluster-scoped store (a
Kyverno restrict-tenant-secret-stores-style policy) so one tenant can't read
another's path. The namespaced store authenticates via the tenant's own Vault
role, scoped to apps/<tenant>/*. The single carve-out is the platform-managed
image-pull secret (below), applied by the GitOps controller, not the tenant.
maxUnavailable: 1, not minAvailable: 1, in the PDB. maxUnavailable: 1
is drain-safe at every replica count — at one replica the pod can still be
evicted (no deadlock), at 2+ it gives rolling protection. A minAvailable: 1
PDB over a single replica permits zero voluntary evictions and wedges every
node drain (autoscaler recycles, rolling reboots).
- If you start under the HA replica floor, opt out explicitly. A cluster that
audits for a minimum replica count (e.g. 3) will flag a fresh single-replica
tenant. Carry the platform's exemption label day-one to stay clean in policy
reports; delete it and raise
replicas when you want HA.
- The
HTTPRoute attaches to the shared gateway via parentRefs
(name/namespace/sectionName), not a per-tenant gateway. Set the
hostnames to the tenant's real host and the backendRefs to the tenant
Service.
Secrets: app secrets vs. the image-pull secret
Two different mechanisms, often confused:
- App secrets (DB creds, API keys) — tenant-owned end-to-end. The platform
provisions only the namespaced
SecretStore + the Vault role/policy (scoped to
apps/<tenant>/*, read and write so the tenant can seed); it never seeds a
tenant's app values. How a value reaches the path is the tenant's business:
paste an externally-issued credential straight into OpenBao, or seed a generated
value in-cluster with a Password generator → PushSecret (refreshInterval: "0") → ExternalSecret. The only hard rules: nothing sensitive sits in git in
plaintext, and workloads read values from the store via ExternalSecrets.
- The image-pull secret (
ghcr-auth / equivalent) — platform-managed, not a
tenant secret. The registration dir ships an ExternalSecret that sources the
shared registry pull credential from the cluster-scoped store and
materialises the dockerconfigjson the OCIRepository and ServiceAccount consume.
It may use the ClusterSecretStore precisely because the GitOps controller (not
the tenant SA) applies it — the policy carves out controller-applied resources.
Platform registration: apps/<tenant>/
Copy an existing tenant directory and rename. The resource set:
| File |
Purpose |
namespace.yaml |
namespace with pod-security.kubernetes.io/enforce: restricted |
serviceaccount.yaml |
SA, automountServiceAccountToken: false, imagePullSecrets: [ghcr-auth] |
rolebinding.yaml |
binds the SA to the edit ClusterRole in the namespace |
networkpolicy.yaml |
platform-side ingress/egress for the tenant |
ghcr-auth-externalsecret.yaml |
image-pull secret from the cluster-scoped store |
secretstore.yaml |
only if the tenant needs app secrets — namespaced store via the tenant Vault role |
sync.yaml |
OCIRepository (semver range, cosign verify) + Kustomization (prune: true, serviceAccountName: <tenant>) |
Then add <tenant>/ to the apps kustomization.yaml. For a tenant that runs its
own external-dns for a custom domain, add the extra grants (an
external-dns-rbac.yaml binding the tenant external-dns SA to the tenant-scoped
ClusterRoles, and an FQDN-pinned external-dns-networkpolicy.yaml) — mirror a
tenant that already does this rather than inventing the RBAC.
In sync.yaml, set the artifact url (oci://<registry>/<tenant>/manifests) and
keep the verify block pointed at the trusted publish-workflow identity, so only
artifacts produced by that workflow are ever reconciled.
Publishing & trust
On every release tag, the tenant's CD calls the platform's signed-publish
workflow: it builds and pushes the image, pins the digest into
deployment.yaml, pushes the manifests as an OCI artifact, and cosign-signs
both (keyless, via CI OIDC). The platform's OCIRepository verifies that
signature against the publish-workflow identity — the trust root that ensures only
artifacts from the trusted pipeline reach the cluster. Tags come from
Conventional-Commit merges to main driving semantic-release, so a normal merge
produces a publish automatically.
Validate before you open the PR
- Render + schema-validate the tenant
deploy/ and the platform registration:
kubectl kustomize <dir> | kubeconform -strict (with built-in schemas + a pinned
CRD catalog for the operators in use). This catches a broken or schema-invalid
manifest before CI.
- Walk the gotcha list above against the diff — container-name == repo-name,
pod-level seccomp, an allow network policy present, namespaced (not cluster)
SecretStore,
maxUnavailable PDB, replica-floor handled, HTTPRoute parent +
hostname set.
- Confirm both halves exist — a tenant repo with no platform registration (or
vice versa) is a half-onboarded tenant that will never reconcile.
Staying current
The tenant template keeps the shared plumbing current via template-sync: it opens
a PR in the tenant whenever a pinned action, a workflow, or a convention changes.
Review and merge it like any dependency update — the files the tenant owns
(deploy/, app code, CI) are listed in .templatesyncignore and never touched.
1---2name: gitops-tenant-onboarding-23description: Onboard a new tenant application onto a Flux-based GitOps platform: scaffold the tenant repo's deploy manifests, wire OpenBao/Vault secrets via External Secrets, fit the cluster's Kyverno/PodSecurity and default-deny network policies, expose the app through a shared Gateway API gateway, and register the tenant on the platform with signed-OCI Flux resources. Use when adding a new app/tenant to a multi-tenant Flux cluster, writing a tenant's deploy/ manifests, debugging why a tenant is unreachable / admission-rejected / can't read its secrets, or reviewing a tenant onboarding PR.4license: Apache-2.05---67# GitOps Tenant Onboarding89Onboarding a **tenant** — an application that runs on a shared, multi-tenant10[Flux](https://fluxcd.io/) cluster from its **own repository** — follows the same11recurring shape every time. The tenant repo builds a container image and publishes12its Kubernetes manifests as a **signed OCI artifact**; the platform pulls that13artifact with a Flux `OCIRepository` + `Kustomization` and runs it in a dedicated,14locked-down namespace. This skill is the agent-facing companion to that pattern:15the decisions, the conventions, and — most usefully — the **gotchas that make a16fresh tenant fail** if you miss them.1718It is built around an opinionated but industry-standard stack — Flux, [External19Secrets Operator](https://external-secrets.io/) backed by OpenBao/Vault,20[Kyverno](https://kyverno.io/) + PodSecurity, Cilium / [Gateway21API](https://gateway-api.sigs.k8s.io/), [cosign](https://www.sigstore.dev/) — so22the steps transfer to any cluster wired the same way; adapt the resource names to23your platform.2425## The two halves2627Onboarding always spans **two repos**, and a tenant is not live until both land:28291. **The tenant repo** — created from a tenant template. Ships the shared,30 framework-agnostic CI/CD plumbing (build → signed publish → release) plus a31 `deploy/` directory of Kubernetes manifests you own and customise.322. **The platform registration** — a small directory in the platform repo33 (`apps/<tenant>/`) that grants the tenant a namespace, an identity (a34 ServiceAccount), RBAC, a network policy, the OpenBao-backed image-pull secret,35 and the Flux resources that pull and verify the tenant's artifact.3637Open each as a PR. The tenant goes live when the **platform registration** merges38and Flux reconciles it — the tenant repo alone does nothing until it is registered.3940## Tenant repo: the `deploy/` manifests4142A tenant's `deploy/` is a Kustomize overlay. The full set, and what each is *for*:4344| Manifest | Purpose | Drop it when… |45|---|---|---|46| `deployment.yaml` | the workload | — |47| `service.yaml` | ClusterIP for the app port | — |48| `httproute.yaml` | Gateway API `HTTPRoute` attaching to the shared gateway | the app is not HTTP-exposed |49| `poddisruptionbudget.yaml` | drain-safe `maxUnavailable: 1` PDB | never — every workload needs one |50| `networkpolicy.yaml` | re-opens ingress/egress under the cluster's default-deny | never on a default-deny cluster |51| `secretstore.yaml` + `externalsecret.yaml` | namespaced External Secrets store + secret | the tenant needs no app secrets |52| `cluster.yaml` | CloudNativePG database | the tenant has no database |53| `kustomization.yaml` | lists the above | — |5455### Conventions and gotchas that bite5657These are the failure modes worth memorising — each one produces a tenant that58*looks* configured but is broken at reconcile, admission, or runtime:5960- **The container `name` MUST equal the repository name.** The signed-publish61 pipeline pins the freshly built image digest into the container named after the62 repo. A mismatch means the running image is never updated. Rename the63 placeholder container throughout `deployment.yaml`.64- **Set the `seccompProfile` at the *pod* level, not only the container.** A65 PodSecurity "restricted" cluster (and a Kyverno `require-seccomp-profile` rule)66 demands `securityContext.seccompProfile.type: RuntimeDefault` on the **pod67 spec**. Setting it only on the container passes naive review but is rejected at68 admission. Set `runAsNonRoot: true` + `seccompProfile` on both pod and69 container; the container also gets `allowPrivilegeEscalation: false`,70 `readOnlyRootFilesystem: true`, `capabilities.drop: [ALL]`.71- **A default-deny cluster makes a tenant unreachable until it ships an allow72 policy.** If the platform generates a deny-all network policy in every tenant73 namespace, a tenant with no `networkpolicy.yaml` cannot receive Gateway traffic74 or reach its own database. Ship the allow policy day-one: ingress from the75 gateway on the app port, egress to DNS, plus intra-namespace + operator rules if76 it has a database. (With Cilium, an empty endpoint selector `{}` in a77 `fromEndpoints`/`toEndpoints` rule selects this namespace's own pods — so78 intra-namespace rules need no namespace name and stay placeholder-free.)79- **Tenant secrets come from the secret store, never SOPS.** No tenant ships an80 encrypted Secret in git. App secrets are delivered by External Secrets from81 OpenBao/Vault (see below). The Flux `Kustomization` needs **no** `spec.decryption`.82- **Use a *namespaced* `SecretStore`, never the shared `ClusterSecretStore`.** A83 multi-tenant cluster blocks tenants from referencing the cluster-scoped store (a84 Kyverno `restrict-tenant-secret-stores`-style policy) so one tenant can't read85 another's path. The namespaced store authenticates via the tenant's own Vault86 role, scoped to `apps/<tenant>/*`. The single carve-out is the platform-managed87 image-pull secret (below), applied by the GitOps controller, not the tenant.88- **`maxUnavailable: 1`, not `minAvailable: 1`, in the PDB.** `maxUnavailable: 1`89 is drain-safe at *every* replica count — at one replica the pod can still be90 evicted (no deadlock), at 2+ it gives rolling protection. A `minAvailable: 1`91 PDB over a single replica permits **zero** voluntary evictions and wedges every92 node drain (autoscaler recycles, rolling reboots).93- **If you start under the HA replica floor, opt out explicitly.** A cluster that94 audits for a minimum replica count (e.g. 3) will flag a fresh single-replica95 tenant. Carry the platform's exemption label day-one to stay clean in policy96 reports; delete it and raise `replicas` when you want HA.97- **The `HTTPRoute` attaches to the *shared* gateway** via `parentRefs`98 (`name`/`namespace`/`sectionName`), not a per-tenant gateway. Set the99 `hostnames` to the tenant's real host and the `backendRefs` to the tenant100 Service.101102## Secrets: app secrets vs. the image-pull secret103104Two different mechanisms, often confused:105106- **App secrets** (DB creds, API keys) — *tenant-owned end-to-end*. The platform107 provisions only the namespaced `SecretStore` + the Vault role/policy (scoped to108 `apps/<tenant>/*`, read **and** write so the tenant can seed); it never seeds a109 tenant's app values. How a value reaches the path is the tenant's business:110 paste an externally-issued credential straight into OpenBao, or seed a generated111 value in-cluster with a `Password` generator → `PushSecret` (`refreshInterval:112 "0"`) → `ExternalSecret`. The only hard rules: nothing sensitive sits in git in113 plaintext, and workloads read values **from the store via `ExternalSecret`s**.114- **The image-pull secret** (`ghcr-auth` / equivalent) — *platform-managed*, not a115 tenant secret. The registration dir ships an `ExternalSecret` that sources the116 shared registry pull credential from the **cluster-scoped** store and117 materialises the dockerconfigjson the `OCIRepository` and ServiceAccount consume.118 It may use the ClusterSecretStore precisely because the GitOps controller (not119 the tenant SA) applies it — the policy carves out controller-applied resources.120121## Platform registration: `apps/<tenant>/`122123Copy an existing tenant directory and rename. The resource set:124125| File | Purpose |126|---|---|127| `namespace.yaml` | namespace with `pod-security.kubernetes.io/enforce: restricted` |128| `serviceaccount.yaml` | SA, `automountServiceAccountToken: false`, `imagePullSecrets: [ghcr-auth]` |129| `rolebinding.yaml` | binds the SA to the `edit` ClusterRole in the namespace |130| `networkpolicy.yaml` | platform-side ingress/egress for the tenant |131| `ghcr-auth-externalsecret.yaml` | image-pull secret from the cluster-scoped store |132| `secretstore.yaml` | *only if the tenant needs app secrets* — namespaced store via the tenant Vault role |133| `sync.yaml` | `OCIRepository` (semver range, cosign `verify`) + `Kustomization` (`prune: true`, `serviceAccountName: <tenant>`) |134135Then add `<tenant>/` to the apps `kustomization.yaml`. For a tenant that runs its136**own** external-dns for a custom domain, add the extra grants (an137`external-dns-rbac.yaml` binding the tenant external-dns SA to the tenant-scoped138ClusterRoles, and an FQDN-pinned `external-dns-networkpolicy.yaml`) — mirror a139tenant that already does this rather than inventing the RBAC.140141In `sync.yaml`, set the artifact `url` (`oci://<registry>/<tenant>/manifests`) and142keep the `verify` block pointed at the trusted publish-workflow identity, so only143artifacts produced by that workflow are ever reconciled.144145## Publishing & trust146147On every release tag, the tenant's CD calls the platform's signed-publish148workflow: it builds and pushes the image, **pins the digest** into149`deployment.yaml`, pushes the manifests as an OCI artifact, and **cosign-signs**150both (keyless, via CI OIDC). The platform's `OCIRepository` verifies that151signature against the publish-workflow identity — the trust root that ensures only152artifacts from the trusted pipeline reach the cluster. Tags come from153Conventional-Commit merges to `main` driving semantic-release, so a normal merge154produces a publish automatically.155156## Validate before you open the PR157158- **Render + schema-validate** the tenant `deploy/` and the platform registration:159 `kubectl kustomize <dir> | kubeconform -strict` (with built-in schemas + a pinned160 CRD catalog for the operators in use). This catches a broken or schema-invalid161 manifest before CI.162- **Walk the gotcha list above** against the diff — container-name == repo-name,163 pod-level seccomp, an allow network policy present, namespaced (not cluster)164 SecretStore, `maxUnavailable` PDB, replica-floor handled, `HTTPRoute` parent +165 hostname set.166- **Confirm both halves exist** — a tenant repo with no platform registration (or167 vice versa) is a half-onboarded tenant that will never reconcile.168169## Staying current170171The tenant template keeps the shared plumbing current via template-sync: it opens172a PR in the tenant whenever a pinned action, a workflow, or a convention changes.173Review and merge it like any dependency update — the files the tenant owns174(`deploy/`, app code, CI) are listed in `.templatesyncignore` and never touched.