An Internal Developer Platform That Hides Kubernetes
Crossplane compositions that turn a Git repo into a fully-wired, secure service that ~60 engineers ship without ever touching raw YAML.
A platform is only as good as the day-to-day experience of the engineers on it. I built an internal developer platform where a service is just a Git repo plus two short specs, and everything that makes it production-grade, the rollout, the identity, the routing, the storage, the backups, is generated for you, correctly, every time.
A service is just a Git repo
On this platform, a service is a Git repo with a small k8s/ folder: a Kustomize base, a per-environment overlay, and two short Crossplane specs. No Helm, no raw Deployments, no hand-written RBAC. The two files that matter are app.yaml (an XApplication) and db.yaml (one XDatabase per datastore); everything else is Kustomize plumbing and per-environment config.
Here is the entire folder a team maintains. Click through it.
The whole k8s/ folder a developer maintains. Click a file to read it (contents sanitized).
apiVersion: platform.example.com/v1alpha1
kind: XApplication
metadata:
name: example
spec:
image: 000000000000.dkr.ecr.us-west-2.amazonaws.com/example:0.0.0
replicas: 1
resources:
cpu: "2"
memory: "1000Mi"
secrets:
- secretName: db-credentials
key: DB_PASSWORD
- secretName: partner-api
key: PARTNER_API_KEY
databases:
- name: exports-store
engine: object-aws
- name: orders-table
engine: document-aws
networking:
routes:
- path: /orders
methods: [GET]
- path: /orders/{orderId}
methods: [GET]
- path: /orders/{orderId}/status
methods: [PUT]
corsOrigins: true
critical: true
vpa: trueThe XApplication: ~20 simple fields the developer writes. The composition turns this into a full service.
The application composition
app.yaml is an XApplication: around twenty simple fields. Behind it, a Crossplane composition (written as a function-pythonic program) expands those fields into every resource a real service needs. The developer never writes a Rollout, an IAM role, or an Istio config; they just declare intent.
Only genuinely cross-cutting knobs are exposed. The clearest example is critical: true: one boolean that places the workload on on-demand capacity with the right node affinity, tolerations, and disruption budgets. That single flag is also the fix from a 28-minute outage I wrote up.
The developer writes ~20 fields. The composition expands them into all of this. Click a resource.
Progressive delivery in place of a Deployment: a canary that only proceeds when its AnalysisRuns pass. If spec.schedule is set, a CronJob is emitted instead.
The database composition
Persistence is a second composition, XDatabase, driven by one engine: flag. A developer asks for the datastore they need and gets a correctly-operated backing store: the right operator, the right topology, and the unglamorous parts provisioned for them.
This is the more interesting of the two, because 'give me a database' hides an enormous amount of per-engine operational knowledge. Ten engines are supported, each mapping to a purpose-built operator or a set of Crossplane managed resources.
A developer sets engine: and gets a correctly-operated datastore. Click an engine.
- A CNPG Cluster: primary plus read replicas, with a -rw and -ro service
- A generated-password Secret (write and read-only principals)
- Barman backups to S3 on a schedule
Most engines get backups baked in, usually to S3, so each also provisions the bucket, its policy, versioning, and the IAM/Pod Identity role to write them. In-cluster stores also get a TLS-deny AuthorizationPolicy and are pinned to the dedicated database node pool.
Backups, buckets, and least privilege, for free
The pattern that ties the database composition together is that requesting any engine also provisions everything that engine needs to be safe and durable. Most stores back up to S3, so selecting an engine also creates the S3 bucket, its policy, versioning, and the IAM/Pod Identity role that lets the store write backups into it. In-cluster stores get a TLS-deny AuthorizationPolicy and land on a dedicated database node pool.
On the application side, access is additive and least-privilege: when an app declares databases: [{engine: object-aws}], the composition attaches a scoped S3 RolePolicy to that app's own role, so one bucket can be shared by many apps without anyone holding broad access.
The compositions, generalized
Both compositions are function-pythonic programs: Python that reads the simple spec and emits the resource graph. The application composition's entry point reads almost like a table of contents for a production service, each line a resource the developer never has to think about:
I've generalized both, stripped the company-specific config, and open-sourced them (linked below) so the pattern is reusable.
def compose(self):
self._setup()
self._service_account()
self._iam_role() # one EKS Pod Identity role per app
self._pod_identity_association() # SA -> role, no static keys
self._secrets_role_policy() # + S3 / DynamoDB / Bedrock, additively
if self._spec.secrets:
self._secret_provider_class() # AWS Secrets Manager CSI
if self._scheduled:
self._cronjob()
else:
self._rollout() # Argo Rollout, not a Deployment
if self._spec.vpa and not self._scheduled:
self._vpa()
if self._spec.hpa and not self._scheduled:
self._scaled_object() # KEDA
if self._has_routes and not self._scheduled:
self._virtual_service() # Istio routing
self._auth_policy_ext_authz(self._authz_eligible_routes())
if self._defaults.get('emit_imageupdater'): # dev
self._image_updater_stable()
self._image_updater_test()
self._git_repository() # GitOps Promoter
self._promotion_strategy()A single pane of glass
Backstage ties it together: service, library, and pipeline docs, OpenAPI specs, ML-model and access metadata, and the composition specs themselves, plus software templates that scaffold a brand-new service in a few clicks. The result is a paved road that's secure by default, where the fast path and the safe path are the same path.