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-onboarding3description: 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---6# GitOps Tenant Onboarding78Onboarding a **tenant** — an application that runs on a shared, multi-tenant9[Flux](https://fluxcd.io/) cluster from its **own repository** — follows the same10recurring shape every time. The tenant repo builds a container image and publishes11its Kubernetes manifests as a **signed OCI artifact**; the platform pulls that12artifact with a Flux `OCIRepository` + `Kustomization` and runs it in a dedicated,13locked-down namespace. This skill is the agent-facing companion to that pattern:14the decisions, the conventions, and — most usefully — the **gotchas that make a15fresh tenant fail** if you miss them.1617It is built around an opinionated but industry-standard stack — Flux, [External18Secrets Operator](https://external-secrets.io/) backed by OpenBao/Vault,19[Kyverno](https://kyverno.io/) + PodSecurity, Cilium / [Gateway20API](https://gateway-api.sigs.k8s.io/), [cosign](https://www.sigstore.dev/) — so21the steps transfer to any cluster wired the same way; adapt the resource names to22your platform.2324## The two halves2526Onboarding always spans **two repos**, and a tenant is not live until both land:27281. **The tenant repo** — created from a tenant template. Ships the shared,29 framework-agnostic CI/CD plumbing (build → signed publish → release) plus a30 `deploy/` directory of Kubernetes manifests you own and customise.312. **The platform registration** — a small directory in the platform repo32 (`apps/<tenant>/`) that grants the tenant a namespace, an identity (a33 ServiceAccount), RBAC, a network policy, the OpenBao-backed image-pull secret,34 and the Flux resources that pull and verify the tenant's artifact.3536Open each as a PR. The tenant goes live when the **platform registration** merges37and Flux reconciles it — the tenant repo alone does nothing until it is registered.3839## Tenant repo: the `deploy/` manifests4041A tenant's `deploy/` is a Kustomize overlay. The full set, and what each is *for*:4243| Manifest | Purpose | Drop it when… |44|---|---|---|45| `deployment.yaml` | the workload | — |46| `service.yaml` | ClusterIP for the app port | — |47| `httproute.yaml` | Gateway API `HTTPRoute` attaching to the shared gateway | the app is not HTTP-exposed |48| `poddisruptionbudget.yaml` | drain-safe `maxUnavailable: 1` PDB | never — every workload needs one |49| `networkpolicy.yaml` | re-opens ingress/egress under the cluster's default-deny | never on a default-deny cluster |50| `secretstore.yaml` + `externalsecret.yaml` | namespaced External Secrets store + secret | the tenant needs no app secrets |51| `cluster.yaml` | CloudNativePG database | the tenant has no database |52| `kustomization.yaml` | lists the above | — |5354### Conventions and gotchas that bite5556These are the failure modes worth memorising — each one produces a tenant that57*looks* configured but is broken at reconcile, admission, or runtime:5859- **The container `name` MUST equal the repository name.** The signed-publish60 pipeline pins the freshly built image digest into the container named after the61 repo. A mismatch means the running image is never updated. Rename the62 placeholder container throughout `deployment.yaml`.63- **Set the `seccompProfile` at the *pod* level, not only the container.** A64 PodSecurity "restricted" cluster (and a Kyverno `require-seccomp-profile` rule)65 demands `securityContext.seccompProfile.type: RuntimeDefault` on the **pod66 spec**. Setting it only on the container passes naive review but is rejected at67 admission. Set `runAsNonRoot: true` + `seccompProfile` on both pod and68 container; the container also gets `allowPrivilegeEscalation: false`,69 `readOnlyRootFilesystem: true`, `capabilities.drop: [ALL]`.70- **A default-deny cluster makes a tenant unreachable until it ships an allow71 policy.** If the platform generates a deny-all network policy in every tenant72 namespace, a tenant with no `networkpolicy.yaml` cannot receive Gateway traffic73 or reach its own database. Ship the allow policy day-one: ingress from the74 gateway on the app port, egress to DNS, plus intra-namespace + operator rules if75 it has a database. (With Cilium, an empty endpoint selector `{}` in a76 `fromEndpoints`/`toEndpoints` rule selects this namespace's own pods — so77 intra-namespace rules need no namespace name and stay placeholder-free.)78- **Tenant secrets come from the secret store, never SOPS.** No tenant ships an79 encrypted Secret in git. App secrets are delivered by External Secrets from80 OpenBao/Vault (see below). The Flux `Kustomization` needs **no** `spec.decryption`.81- **Use a *namespaced* `SecretStore`, never the shared `ClusterSecretStore`.** A82 multi-tenant cluster blocks tenants from referencing the cluster-scoped store (a83 Kyverno `restrict-tenant-secret-stores`-style policy) so one tenant can't read84 another's path. The namespaced store authenticates via the tenant's own Vault85 role, scoped to `apps/<tenant>/*`. The single carve-out is the platform-managed86 image-pull secret (below), applied by the GitOps controller, not the tenant.87- **`maxUnavailable: 1`, not `minAvailable: 1`, in the PDB.** `maxUnavailable: 1`88 is drain-safe at *every* replica count — at one replica the pod can still be89 evicted (no deadlock), at 2+ it gives rolling protection. A `minAvailable: 1`90 PDB over a single replica permits **zero** voluntary evictions and wedges every91 node drain (autoscaler recycles, rolling reboots).92- **If you start under the HA replica floor, opt out explicitly.** A cluster that93 audits for a minimum replica count (e.g. 3) will flag a fresh single-replica94 tenant. Carry the platform's exemption label day-one to stay clean in policy95 reports; delete it and raise `replicas` when you want HA.96- **The `HTTPRoute` attaches to the *shared* gateway** via `parentRefs`97 (`name`/`namespace`/`sectionName`), not a per-tenant gateway. Set the98 `hostnames` to the tenant's real host and the `backendRefs` to the tenant99 Service.100101## Secrets: app secrets vs. the image-pull secret102103Two different mechanisms, often confused:104105- **App secrets** (DB creds, API keys) — *tenant-owned end-to-end*. The platform106 provisions only the namespaced `SecretStore` + the Vault role/policy (scoped to107 `apps/<tenant>/*`, read **and** write so the tenant can seed); it never seeds a108 tenant's app values. How a value reaches the path is the tenant's business:109 paste an externally-issued credential straight into OpenBao, or seed a generated110 value in-cluster with a `Password` generator → `PushSecret` (`refreshInterval:111 "0"`) → `ExternalSecret`. The only hard rules: nothing sensitive sits in git in112 plaintext, and workloads read values **from the store via `ExternalSecret`s**.113- **The image-pull secret** (`ghcr-auth` / equivalent) — *platform-managed*, not a114 tenant secret. The registration dir ships an `ExternalSecret` that sources the115 shared registry pull credential from the **cluster-scoped** store and116 materialises the dockerconfigjson the `OCIRepository` and ServiceAccount consume.117 It may use the ClusterSecretStore precisely because the GitOps controller (not118 the tenant SA) applies it — the policy carves out controller-applied resources.119120## Platform registration: `apps/<tenant>/`121122Copy an existing tenant directory and rename. The resource set:123124| File | Purpose |125|---|---|126| `namespace.yaml` | namespace with `pod-security.kubernetes.io/enforce: restricted` |127| `serviceaccount.yaml` | SA, `automountServiceAccountToken: false`, `imagePullSecrets: [ghcr-auth]` |128| `rolebinding.yaml` | binds the SA to the `edit` ClusterRole in the namespace |129| `networkpolicy.yaml` | platform-side ingress/egress for the tenant |130| `ghcr-auth-externalsecret.yaml` | image-pull secret from the cluster-scoped store |131| `secretstore.yaml` | *only if the tenant needs app secrets* — namespaced store via the tenant Vault role |132| `sync.yaml` | `OCIRepository` (semver range, cosign `verify`) + `Kustomization` (`prune: true`, `serviceAccountName: <tenant>`) |133134Then add `<tenant>/` to the apps `kustomization.yaml`. For a tenant that runs its135**own** external-dns for a custom domain, add the extra grants (an136`external-dns-rbac.yaml` binding the tenant external-dns SA to the tenant-scoped137ClusterRoles, and an FQDN-pinned `external-dns-networkpolicy.yaml`) — mirror a138tenant that already does this rather than inventing the RBAC.139140In `sync.yaml`, set the artifact `url` (`oci://<registry>/<tenant>/manifests`) and141keep the `verify` block pointed at the trusted publish-workflow identity, so only142artifacts produced by that workflow are ever reconciled.143144## Publishing & trust145146On every release tag, the tenant's CD calls the platform's signed-publish147workflow: it builds and pushes the image, **pins the digest** into148`deployment.yaml`, pushes the manifests as an OCI artifact, and **cosign-signs**149both (keyless, via CI OIDC). The platform's `OCIRepository` verifies that150signature against the publish-workflow identity — the trust root that ensures only151artifacts from the trusted pipeline reach the cluster. Tags come from152Conventional-Commit merges to `main` driving semantic-release, so a normal merge153produces a publish automatically.154155## Validate before you open the PR156157- **Render + schema-validate** the tenant `deploy/` and the platform registration:158 `kubectl kustomize <dir> | kubeconform -strict` (with built-in schemas + a pinned159 CRD catalog for the operators in use). This catches a broken or schema-invalid160 manifest before CI.161- **Walk the gotcha list above** against the diff — container-name == repo-name,162 pod-level seccomp, an allow network policy present, namespaced (not cluster)163 SecretStore, `maxUnavailable` PDB, replica-floor handled, `HTTPRoute` parent +164 hostname set.165- **Confirm both halves exist** — a tenant repo with no platform registration (or166 vice versa) is a half-onboarded tenant that will never reconcile.167168## Staying current169170The tenant template keeps the shared plumbing current via template-sync: it opens171a PR in the tenant whenever a pinned action, a workflow, or a convention changes.172Review and merge it like any dependency update — the files the tenant owns173(`deploy/`, app code, CI) are listed in `.templatesyncignore` and never touched.