gitops-tenant-onboarding
Skill3 starsChanged 11 days agoApache-2.0View on GitHub
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.
In this repository16 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- agent-improvementSkill
- agent-instructionsSkill
- allowed-stack-guardrailSkill
- conventional-releaseSkill
- jargon-free-voiceSkill
- needs-stack-mappingSkill
- portfolio-maintenanceSkill
- product-documentationSkill
- product-engineeringSkill
- self-improvementSkill
- ways-of-workingSkill
- write-contributingSkill
- write-docs-introductionSkill
- write-readmeSkill
The file
We split this skill into the rules it gives an agent
1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 87
Tenant secrets come from the secret store, never SOPS: No tenant ships an encrypted Secret in git.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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
nameMUST 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 throughoutdeployment.yaml. - Set the
seccompProfileat the pod level, not only the container. A PodSecurity "restricted" cluster (and a Kyvernorequire-seccomp-profilerule) demandssecurityContext.seccompProfile.type: RuntimeDefaulton the pod spec. Setting it only on the container passes naive review but is rejected at admission. SetrunAsNonRoot: true+seccompProfileon both pod and container; the container also getsallowPrivilegeEscalation: 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.yamlcannot 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 afromEndpoints/toEndpointsrule 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
Kustomizationneeds nospec.decryption. - Use a namespaced
SecretStore, never the sharedClusterSecretStore. A multi-tenant cluster blocks tenants from referencing the cluster-scoped store (a Kyvernorestrict-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 toapps/<tenant>/*. The single carve-out is the platform-managed image-pull secret (below), applied by the GitOps controller, not the tenant. maxUnavailable: 1, notminAvailable: 1, in the PDB.maxUnavailable: 1is drain-safe at every replica count — at one replica the pod can still be evicted (no deadlock), at 2+ it gives rolling protection. AminAvailable: 1PDB 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
replicaswhen you want HA. - The
HTTPRouteattaches to the shared gateway viaparentRefs(name/namespace/sectionName), not a per-tenant gateway. Set thehostnamesto the tenant's real host and thebackendRefsto 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 toapps/<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 aPasswordgenerator →PushSecret(refreshInterval: "0") →ExternalSecret. The only hard rules: nothing sensitive sits in git in plaintext, and workloads read values from the store viaExternalSecrets. - The image-pull secret (
ghcr-auth/ equivalent) — platform-managed, not a tenant secret. The registration dir ships anExternalSecretthat sources the shared registry pull credential from the cluster-scoped store and materialises the dockerconfigjson theOCIRepositoryand 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,
maxUnavailablePDB, replica-floor handled,HTTPRouteparent + 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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

