IDP & GitOps Reference Architecture
Internal Developer Platform for zero-touch service onboarding and multi-tenant GitOps delivery, driven by one versioned catalog that holds both the golden paths and the output contract — no scaffolder code contains a hardcoded path.
- IDP
- GitOps
- Argo CD
- Kubernetes
- Terraform
- Go
The problem
Onboarding a new service means a ticket, a wait, and a platform engineer hand-assembling the same manifests again — the platform team becomes the bottleneck for every team it serves.
Constraints
- Developers must self-serve without needing cluster access
- Multi-tenant isolation cannot depend on tenants behaving correctly
- Cluster state must be reconstructible from Git alone
- The platform must be able to restructure its output without a code change
Architecture
Catalog
Developer
Delivery
Control plane
Key decisions
What was chosen, what it was chosen over, and why.
Chose
The catalog owns the output contract, not the scaffolder
Instead of
Output paths written into the generator
A `destinations` table maps every catalog source directory to its output path, so where a file lands is data the platform team edits, not logic they redeploy. Restructuring the whole tenant tree is a YAML change, and the loader rejects a catalog with a missing key before anything is written rather than halfway through.
Chose
Argo CD ApplicationSet with a directory generator
Instead of
One Application manifest per service
Onboarding becomes a directory appearing in Git rather than a platform-team ticket, which is what makes zero-touch provisioning possible at all rather than merely automated.
Chose
Version-pinned Terraform modules behind capability names
Instead of
Crossplane compositions as the infrastructure interface
A service declares `postgres`, and the catalog resolves that to a specific module at a specific tag. Crossplane bought a Kubernetes-native API at the cost of a second abstraction to author and debug for infrastructure Terraform already described. Pinning is what actually protects tenants: the platform ships a module change without touching a tenant repo, and a bad one is a pin rollback rather than an incident.
Also decided
Kyverno admission controlnotManual compliance review before merge
Guardrails over gates: the control plane enforces the boundary at admission, so a tenant cannot opt out by editing their own manifests and the platform team is not a queue.
Tenant-first monorepo whose paths are the split prefixesnotSeparate repositories from day one
Output is `<team>/{apps,infra,gitops}/`, and those three paths are exactly the `git subtree split` prefixes. The demo stays one repo while the production shape — code, Terraform and desired state under different reviewers and blast radius — is already encoded, so splitting is a no-op rather than a reshuffle.
Ownership enforced by CODEOWNERS at each would-be repo rootnotDocumenting which directories the platform team owns
GitHub honours CODEOWNERS only at a repo root, so nesting it under the platform directory would have made it decorative. Inside `infra/` and `gitops/` the split is by ownership rather than taxonomy, which reduces the whole rule to two globs.
Backstage System as metadata, not a directory levelnotA `<system>/` directory under each team
Reversed after building it. The justifications did not survive scrutiny — Terraform blast radius is set by where `apply` runs, and a team ApplicationSet globbing `apps/*/*` discovers services perfectly well. It now lives only in `catalog-info.yaml`, where it cannot drift from a second encoding in the path.
Two independent scaffolder engines against one catalognotA single implementation and a claim that the catalog is a contract
Go and Python both render the catalog, and their output trees are diffed byte-for-byte in CI. A difference means either the engines drifted or the catalog under-specified something both had to guess — so the contract is falsifiable rather than asserted. In production you would ship one; here it is the test.