An enterprise-grade Internal Developer Platform (IDP) blueprint designed for zero-touch onboarding, strict policy governance, and seamless multi-tenant continuous delivery via GitOps.
This repository serves as a reference architecture for platform teams looking to build "Local-to-Cloud" environments where developers are shielded from infrastructure complexity while retaining full deployment autonomy.
As cloud-native architectures scale, cognitive load on product engineering teams becomes a critical bottleneck. This reference architecture is built on the philosophy of Platform-as-a-Product. The platform itself is the product, and the application teams are its customers.
We deliver this product experience through four core pillars:
- The UX (Golden Paths): Curated, paved-road templates that provide secure-by-default microservice runtimes and delivery pipelines.
- The Self-Service Portal (Go CLI): A custom Golang CLI that allows developers to instantly scaffold applications, GitOps manifests, and infrastructure without platform team intervention.
- The Stable API (Versioned IaC): Infrastructure-as-Code is abstracted into reusable, version-pinned AWS Terraform modules (
?ref=vX), ensuring we never break our customers. - The Reconciliation Engine (GitOps): Strict unidirectional state flow. ArgoCD auto-discovers workloads via ApplicationSets, while Terraform state is strictly isolated per-service-per-environment to minimize blast radius.
To make it easier to understand how this platform operates from end-to-end, the repository is logically divided into chronological layers:
1-platform-catalog/(The Platform's Offering)
catalog.yamldeclares the golden paths, the offered runtimes, the version-pinned capability โ Terraform module mapping, and adestinations:table that is the platform's output contract. Alongside it sit three directories distinguished by what happens to the files in them:blueprints/is copied once per team,building-blocks/is copied per service, andcharts/is never copied at all โ CI renders it and only the output reaches a tenant repo. Templates use[[ .Var ]]syntax so Helm's{{ }}passes through untouched.2-idp-scaffolder/golang/(Go Scaffolder Engine)
Go CLI implementation using Cobra and nativetext/templateto render microservice workloads. This is the definitive engine.2-idp-scaffolder/python/(Python Scaffolder Engine & REST API)
Python CLI and FastAPI REST service using Typer, Jinja2 (configured with Go's[[ ]]delimiters) and pydantic validation, plus deterministic IP Address Management (IPAM) for tenant VPCs. It implements the same two verbs against the same catalog โ see One Catalog, Two Engines below for why.3-tenant-workloads/(Simulated Monorepo)
The generated output, organised tenant-first as<team>/{apps,infra,gitops}/โ each of those three maps to a standalone repo in production.apps/always means team-owned per-service content; the enclosing repo kind says whether that is source code, Terraform, or Helm values. There is no<system>/directory level โ Backstage's System grouping lives incatalog-info.yamlinstead. Insideinfra/andgitops/,platform/is platform-owned, and a CODEOWNERS at each of the three roots makes that enforceable. ArgoCD monitors the CI-renderedmanifests/directories for automatic deployment.4-platform-engineering/(Platform Infrastructure & Control Plane)
Contains reusable AWS Terraform modules (cloud-services-terraform-modules/), ArgoCD App-of-Apps declarations (argocd-apps/), Traefik ingress controller setup, and OpenTelemetry observability configurations.
The catalog has three top-level directories, and the thing that distinguishes them is what happens to the files inside:
| Catalog directory | Rendered by | When | What reaches a tenant repo |
|---|---|---|---|
blueprints/ |
Scaffolder CLI / API โ onboard-team |
once per team | the files themselves, copied |
building-blocks/ |
Scaffolder CLI / API โ add-service |
once per service | the files themselves, copied |
charts/ |
GitHub Actions, never the CLI | every push touching a values.yaml or the chart |
only its rendered output, into manifests/ |
That third row is the one people trip over. The chart is not a template the scaffolder copies โ nothing under charts/ ever appears in 3-tenant-workloads/. One platform-owned chart serves every service, CI runs helm template against each app's values.yaml, and only the resulting plain YAML is committed. A chart fix therefore ships fleet-wide instead of being copy-pasted into N repos, and ArgoCD syncs manifests/ only.
The first two directories are also laid out differently from each other, on purpose. blueprints/ is walked and copied wholesale, so it mirrors the tree it produces. building-blocks/ is selected from by name โ runtime: go, capabilities: [postgres] โ so it is organised by the keys catalog.yaml uses to address it. Two different access patterns, two different layouts.
What ties the catalog to its output is therefore not the directory names but this table:
| Catalog source | Rendered | Lands at |
|---|---|---|
blueprints/team/apps/ |
once per team | <team>/apps/ |
blueprints/team/infra/ |
once per team | <team>/infra/ |
blueprints/team/gitops/ |
once per team | <team>/gitops/ |
building-blocks/runtimes/<lang>/ |
per service | <team>/apps/<app>/ |
building-blocks/service-meta/ |
per service | <team>/apps/<app>/ |
building-blocks/capabilities/<cap>.tf.tmpl |
per service, per capability | <team>/infra/apps/<app>/<env>/ |
building-blocks/delivery/release/ |
per service | <team>/gitops/apps/<app>/<env>/ |
charts/service/ |
never scaffolded | CI renders it into <team>/gitops/apps/<app>/<env>/manifests/ |
Every row except the last is the destinations: block in catalog.yaml, verbatim โ the CLI reads that same data to decide where to write, so this table cannot drift from the code. Restructuring 3-tenant-workloads/ is a YAML edit, not a Go change.
The last row is the exception worth understanding: the chart is never copied into a tenant repo. One platform-owned chart serves every service, so a chart fix ships fleet-wide instead of being copy-pasted into N repos. Teams own values.yaml; the platform owns the chart; CI renders one against the other and commits only the result. That is why it lives in charts/ rather than building-blocks/ โ everything under building-blocks/ gets copied, and this gets rendered.
One command shows the entire mapping better than any diagram:
$ make demo-add-service DEMO_TEAM=payments DEMO_APP=checkout-api
Generating app 'checkout-api' [Runtime: go, Capabilities: [postgres]]
building-blocks/runtimes/go/go.mod.tmpl --> payments/apps/checkout-api/go.mod
building-blocks/runtimes/go/main.go.tmpl --> payments/apps/checkout-api/main.go
building-blocks/service-meta/catalog-info.yaml.tmpl --> payments/apps/checkout-api/catalog-info.yaml
building-blocks/delivery/release/values.yaml.tmpl --> payments/gitops/apps/checkout-api/dev/values.yaml
Adding infrastructure capability: postgres
building-blocks/capabilities/postgres.tf.tmpl --> payments/infra/apps/checkout-api/dev/postgres.tfFive files, three would-be repos, one command. Note what is absent: no Chart.yaml, no Helm packaging in apps/, and nothing written outside dev/ โ production requires a deliberate promotion PR.
The CLI itself writes to the current directory and appends nothing to it, the same contract as terraform or npm. make demo-add-service passes --output-root "$(git rev-parse --show-toplevel)/3-tenant-workloads", so the simulation path is named in the Makefile rather than compiled into a binary other people would run.
3-tenant-workloads/ is the authoring format. The production format is three repos per team, and the transformation is not a feature that needs writing โ it is git subtree split, whose prefixes the layout was designed around:
$ git subtree split --prefix=3-tenant-workloads/team-a/apps
1f8a94e38604a7ff63e3e2d909fab5ecca8279af
$ git ls-tree -r --name-only 1f8a94e3
CODEOWNERS
app-a/catalog-info.yaml
app-a/go.mod
app-a/main.goRun it yourself. Those are real commits carrying the full history of that prefix, not a plan โ though the SHA is derived from that history, so yours will differ once anything new lands under team-a/apps/. The tree listing is the part that matters.
Notice what the split removes. In the monorepo the path is team-a/apps/app-a/main.go; in the resulting <org>/team-a-apps repo it is app-a/main.go. The {team}/ and apps/ segments exist in the view that needs them โ telling teams and artifact kinds apart in one tree โ and vanish from the view where they would be noise, because a repo that already is team-a's application code does not need to say so twice. CODEOWNERS lands at the split repo's root, which is the only place GitHub honours it.
That is why there is no --layout flag. A second output mode would mean a second code path to keep in sync and a mode for a user to get wrong; the split gives the same result with neither.
This platform enforces a strict, unidirectional flow of state. Kubernetes is treated as the source of truth, and Argo CD acts as the reconciliation engine.
graph TD
subgraph Developer Experience
Dev[Product Engineer] --> |Uses Scaffolder CLI| Code[Generates App & Manifests]
Code --> |Pushes to| GitSource[(Tenant Git Repository)]
end
subgraph CI/CD Orchestration
GitSource --> |Triggers| GHA[GitHub Actions]
GHA --> |Tests & Compiles| Rendered[Rendered Manifests]
Rendered --> |Commits to| GitOpsRepo[(Tenant GitOps Repo)]
end
subgraph Platform Control Plane
AppSet((Argo CD ApplicationSet)) -.-> |Scans for new apps| GitOpsRepo
GitOpsRepo --> |Syncs State| ArgoCD[Argo CD Core]
ArgoCD --> |Reconciles| K8s[Kubernetes Cluster]
end
style Developer Experience fill:#1f2937,stroke:#4b5563,stroke-width:2px,color:#fff
style CI/CD Orchestration fill:#1e3a8a,stroke:#3b82f6,stroke-width:2px,color:#fff
style Platform Control Plane fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#fff
To scale across hundreds of microservices, we utilize Argo CD ApplicationSets. Instead of manually mapping each microservice to an Argo CD Application resource, discovery is two-level: a cluster-wide bootstrap ApplicationSet globs 3-tenant-workloads/*/gitops/platform/applicationsets to apply each team's system ApplicationSets, and each of those in turn globs 3-tenant-workloads/<team>/gitops/apps/*/* (app ร env) to provision and isolate tenant applications on the fly.
Most reference architectures claim their catalog is "the contract" and leave it at that. Here the claim is falsifiable.
1-platform-catalog/catalog.yaml is consumed by two independent implementations โ Go (text/template, Cobra) and Python (Jinja2, Typer, pydantic). Run both with the same inputs and diff the trees:
diff -r /tmp/go-out/3-tenant-workloads/payments 3-tenant-workloads/paymentsIf the output differs, one of two things is true: the engines have drifted, or the catalog is under-specified about something both had to guess. Both are findings worth having. Today the two trees are byte-identical โ every file, both verbs.
This is also why the templates carry no logic beyond one conditional, why output paths live in catalog.yaml's destinations: table rather than in either codebase, and why both engines validate the same required keys at load time. A contract that only one implementation reads is just a config file.
Two engines is a deliberate teaching choice, not a production recommendation. In a real platform you would ship one and spend the saved effort on Day-2 concerns โ
--dry-run, idempotent regeneration, catalog version pinning. Those are exactly the items in the twoTODO.mdfiles.
This blueprint integrates best-in-class cloud-native tooling to form a cohesive ecosystem:
| Capability | Technology | Architectural Purpose |
|---|---|---|
| Local Cluster | K3d (K3s) | Lightweight, ephemeral Kubernetes environment optimized for ARM64/Silicon. |
| GitOps Engine | Argo CD | Declarative CD, state reconciliation, and multi-tenant auto-discovery. |
| Infra as Code | Crossplane | Abstracts AWS/Azure infrastructure into higher-level Kubernetes Claims. |
| Policy as Code | Kyverno | Admission control. Enforces cluster security boundaries and standards. |
| Prog. Delivery | Argo Rollouts | Automated Canary & Blue-Green deployments integrated with edge routing. |
| Edge Gateway | Traefik | L7 ingress, API gateway, rate-limiting, and middleware injection. |
| Secrets Ops | Sealed Secrets | Asymmetric encryption enabling safe storage of secrets in Git. |
| Observability | Grafana Stack | Unified metrics (Prometheus), logs (Loki), and traces (Tempo). |
| Dep. Management | Renovate | Automated dependency bumps. go.mod and pyproject.toml are covered by the built-in managers; one custom regex manager handles the version pins in catalog.yaml, which no package manager understands. |
You can spin up this entire reference architecture locally to evaluate the developer experience and platform guardrails. We provide a Makefile to simplify the setup process.
make create-clustermake install-argocdThe bootstrap.yaml file acts as the root of the "App of Apps" pattern. It points Argo CD to the 4-platform-engineering/argocd-apps/ directory to deploy all cluster add-ons simultaneously.
make bootstrapTip: You can also run
make setupto perform all cluster provisioning, Argo CD installation, and bootstrapping in one command.
Emulate a developer onboarding a new service. The generator builds the source code, pipelines, and GitOps configurations. The Go CLI exposes two lifecycle verbs, each idempotent at a different scope:
cd 2-idp-scaffolder/golang
# 1. Once per team โ tenancy boundary (AppProject, Namespace, NetworkPolicy, team IAM)
# plus the ArgoCD ApplicationSet that auto-discovers everything the team owns
go run . onboard-team --catalog-root ../../1-platform-catalog --team-name payments
# 2. Repeatable โ a golden path: runtime + capabilities + delivery values + catalog-info
go run . add-service --catalog-root ../../1-platform-catalog \
--team-name payments --app-name checkout-api \
--golden-path go-service-postgres --system checkout--golden-path seeds the runtime and capabilities from catalog.yaml; --runtime and
--capabilities override or extend that seed. --system is optional Backstage metadata;
it groups services in the service catalog and creates no directory.
--catalog-rootmatters. Without it the CLI fetches the catalog from GitHub, so your local edits to1-platform-catalog/will not take effect until pushed.--output-rootlikewise redirects the generated tree somewhere other than3-tenant-workloads/.
The same verbs in the Python engine, which also exposes them over REST:
cd 2-idp-scaffolder/python && uv sync
uv run python main.py onboard-team --team-name payments
uv run python main.py add-service --team-name payments --app-name checkout-api \
--golden-path go-service-postgres
make run-api # FastAPI on the same engine; open /docs for the OpenAPI UINot yet idempotent. Both engines use truncating writes, so re-running
add-serviceoverwrites edits to a previously scaffolded file. Skip-if-exists and a working--dry-runare the next items in bothTODO.mdfiles.
If you make changes to the YAML files inside the 4-platform-engineering/ directory, ArgoCD is configured to automatically sync the changes from the main branch.
To manually trigger a sync or force an update without waiting for Git polling, you can apply the bootstrap file again:
make bootstrapOr force a sync via the ArgoCD CLI:
argocd app sync platform-bootstrapTo delete the ephemeral cluster and completely destroy the environment, run:
make destroyThis will remove the cluster (via K3d/Kind/Minikube) and clean up all resources.
To mature this architecture for production environments, the following capabilities are roadmapped:
- Backstage Integration: Migrating the python CLI generator into Backstage Software Templates for a unified GUI developer portal.
- AIOps / Observability: Full instrumentation using OpenTelemetry to map service dependencies and reduce MTTR via correlation.
- FinOps Automation: Operator-driven cost controls to scale non-production idle workloads to zero using KEDA.
- Cross-Cloud Capabilities: Expanding Crossplane Compositions to support Azure AKS and GCP GKE multi-cloud environments.