The problem: infrastructure requests still go through a queue

Ask a developer how they get a new database, and in most organizations the honest answer is still: open a ticket, wait, and hope the person who provisions it remembers all the tagging, encryption, and network rules from last time. That queue is not a tooling gap — teams already have Terraform, Helm, and CI/CD. It is an interface gap. Infrastructure-as-code made resources repeatable; it never made them self-service.

Platform engineering closes that gap by giving developers a paved road: a catalog of approved things they can request themselves, with the guardrails already built in. Crossplane and Backstage are the two most common open-source building blocks for that road, and they solve different halves of the problem.

The short version: Backstage is the front door developers see. Crossplane is the control plane that makes their request real. GitOps is the hinge that connects the two safely.

What each piece actually does

  • Backstage — a CNCF-graduated developer portal. Its Software Catalog tracks every service and its owner, TechDocs keeps documentation next to the code it describes, and the Scaffolder turns a form into a pull request. Backstage does not provision anything itself — it produces the request.
  • Crossplane — a Kubernetes extension that turns the Kubernetes API into a control plane for anything, not just pods. Providers (for AWS, Azure, GCP, and dozens of others) add CRDs for real cloud resources. Compositions let a platform team define a simplified abstraction — "PostgreSQLInstance" — that expands into the ten or so real resources it actually needs (the database, a security group, a parameter group, IAM, backups).
  • GitOps (Argo CD / Flux) — the delivery mechanism that keeps the two in sync. It watches a Git repository and continuously reconciles the cluster to match it, so "merged to main" and "running in the cluster" never drift apart.

None of these tools is aware of the other two by design. That separation is the whole point: Backstage never touches a kubeconfig, Crossplane never touches Git, and the GitOps controller is the only thing with write access to the cluster. Each piece can be swapped without the others noticing.

The ecosystem architecture

The diagram below is the shape almost every Crossplane-plus-Backstage platform converges on. A developer never files a ticket and never writes YAML by hand — they fill out a form, and everything from that point on is machine-to-machine.

Crossplane and Backstage internal developer platform architecture A developer submits a Backstage Scaffolder template, which opens a pull request containing a Crossplane claim and a catalog-info.yaml file to a Git repository. A GitOps controller such as Argo CD or Flux watches the repository and applies the merged manifests to the Kubernetes API server. Crossplane reconciles the claim into a composite resource and managed resources, which are created by provider-aws, provider-azure, provider-gcp, or provider-kubernetes against real cloud APIs. Status and connection secrets flow back up through Crossplane and the Kubernetes API to the Backstage Kubernetes plugin, closing the loop back to the developer portal. Developer Fills out a self-service form 1 · submits template Backstage Developer Portal Software Catalog Scaffolder Templates TechDocs 2 · opens a PR: claim + catalog-info.yaml Git Repository Claim YAML, reviewed like code GitOps Controller Argo CD / Flux — watch, sync, self-heal 3 · merge triggers sync · 4 · applies manifests Kubernetes API Server — the universal control-plane interface 5 · Crossplane reconciles the claim Crossplane Control Plane XRD (API schema) Composition Managed Resources 6 · composition selects provider(s) provider-aws RDS · S3 · IAM provider-azure AKS · Storage · Key Vault provider-gcp Cloud SQL · GCS provider-kubernetes Helm · namespaces · RBAC 7 · status & connection secrets stream back

Backstage originates the request, Git and the GitOps controller carry it, and Crossplane executes it — status flows back through the same path, so the catalog always reflects reality.

Reading the diagram left to right in time: a developer fills out a Scaffolder form (1); the Scaffolder's publish:github action opens a pull request containing a Crossplane claim and a catalog-info.yaml (2); once reviewed and merged, the GitOps controller notices the change (3) and applies it to the cluster (4); the Kubernetes API server accepts the claim and Crossplane reconciles it (5) through an XRD, a Composition, and the managed resources the Composition expands into; the Composition selects the right provider for the target cloud (6); and as those managed resources come up, their status and any generated connection secrets stream back up through Crossplane and the Kubernetes API to the Backstage Kubernetes plugin (7), which is what flips the catalog entry to "Ready."

Where GitOps fits: Git as the control point

It's tempting to draw Backstage talking directly to the cluster — the Scaffolder is, after all, capable of running arbitrary actions. Nearly every production setup deliberately avoids that shortcut, and the reason is GitOps.

Argo CD or Flux sitting between the repo and the cluster turns three loose tools into one accountable system:

  • Git becomes the approval gate. The Scaffolder never has cluster credentials — it only has permission to open a pull request. A human (or a policy bot) reviews the actual claim before anything reaches Kubernetes. That single fact is usually what gets a self-service platform past a security review.
  • The cluster can't drift silently. Argo CD and Flux both continuously reconcile — if someone kubectl edits a claim directly, or a resource is deleted out-of-band, the controller notices the live state no longer matches Git and either reports it or reverts it, depending on whether auto-sync and self-heal are enabled.
  • Rollback is git revert. Because the claim, the Composition selection, and the environment overlay all live in Git, undoing a bad change is the same operation every engineer already knows, not a separate infrastructure incident process.
  • Promotion is a directory, not a rewrite. The common pattern is one claims repo with a folder per environment (dev/, staging/, prod/) or an Argo CD app-of-apps per cluster. Promoting a change from staging to prod is copying a file between folders and opening a PR, not re-deriving it.

Concretely, that means the "provider" your Composition targets and the size of the database a developer can request are policy decisions enforced at two layers at once: the Composition schema (what's structurally possible) and the Git branch protection rules (who can approve what, for which environment). Crossplane never has to know about approvals — it only ever reconciles what's already merged.

Step-by-step installation

1. Prerequisites

You need a running Kubernetes cluster (a local kind cluster is enough to follow along; EKS/AKS/GKE for anything real), kubectl, Helm 3, cloud credentials for whichever provider you're targeting, and Node.js 20+ for the Backstage app.

2. Install Crossplane

Terminal

helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update

helm install crossplane crossplane-stable/crossplane \
  --namespace crossplane-system --create-namespace

kubectl get pods -n crossplane-system

3. Install a provider and configure credentials

Providers are themselves packages that Crossplane installs and manages. This example targets AWS.

provider-aws.yaml

apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-rds
spec:
  package: xpkg.upbound.io/upbound/provider-aws-rds:v1

---
apiVersion: v1
kind: Secret
metadata:
  name: aws-creds
  namespace: crossplane-system
type: Opaque
stringData:
  creds: |
    [default]
    aws_access_key_id = <redacted>
    aws_secret_access_key = <redacted>

---
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-creds
      key: creds
kubectl apply -f provider-aws.yaml
kubectl get providers

4. Define the abstraction: an XRD and a Composition

This is the platform team's actual product: a simplified API that hides the ten AWS resources a real Postgres instance needs behind one developer-facing kind.

xrd-postgresql.yaml

apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresqlinstances.platform.example.org
spec:
  group: platform.example.org
  names:
    kind: XPostgreSQLInstance
    plural: xpostgresqlinstances
  claimNames:
    kind: PostgreSQLInstance
    plural: postgresqlinstances
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    storageGB:
                      type: integer
                    environment:
                      type: string
                      enum: ["dev", "staging", "prod"]
                  required: ["storageGB", "environment"]
              required: ["parameters"]

The Composition behind it is where guardrails live: mandatory tags, encryption at rest, private-subnet-only networking, and an instance-size ceiling for dev claims — none of which the developer ever sees or can override.

5. Install Backstage

Terminal

npx @backstage/create-app@latest --path my-idp
cd my-idp
yarn install
yarn dev

6. Add the Kubernetes plugin so claim status shows up in the portal

yarn --cwd packages/app add @backstage/plugin-kubernetes
yarn --cwd packages/backend add @backstage/plugin-kubernetes-backend

Then point app-config.yaml at the cluster and label-select for the Composite Resources the Composition creates, so each catalog entry's Kubernetes tab shows the claim's live status and conditions.

7. Build the golden-path Scaffolder template

The template takes environment and storageGB as form inputs, renders a PostgreSQLInstance claim plus a catalog-info.yaml, and uses the publish:github action to open a pull request against the claims repo — no cluster access required from Backstage at any point.

8. Wire GitOps

Terminal

helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd -n argocd --create-namespace

argocd app create platform-claims \
  --repo https://github.com/your-org/platform-claims.git \
  --path environments/dev \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace default \
  --sync-policy automated \
  --self-heal

9. Verify the golden path end to end

Fill out the form in Backstage, approve the resulting PR, merge it, and watch: Argo CD syncs within its poll interval, Crossplane reconciles the claim into a real RDS instance, and the catalog entry's Kubernetes tab flips to Ready with the generated connection secret name attached. That whole loop, once wired, typically runs in minutes rather than the days a ticket queue takes.

What this actually changes for developers

The architecture is the means; the point is what it removes from a developer's day.

  • Time-to-first-database drops from days to minutes because provisioning is no longer a queue behind a human, it's a reconciliation loop behind a form.
  • Guardrails are structural, not procedural. A developer requesting a dev database physically cannot request an unencrypted instance or a public subnet, because those options were never exposed by the Composition. Compliance stops being a review step and becomes a property of the abstraction.
  • Ownership and docs live in one place. The Software Catalog answers "who owns this and where are the docs" for every service, and TechDocs keeps the answer next to the code instead of in a wiki that drifts out of date.
  • Everything is auditable by construction. Every infrastructure change is a merged pull request with a named approver, which is exactly the evidence trail SOC 2 and internal audits ask for — without anyone building a separate audit system.
  • The platform team scales by adding templates, not headcount. A new kind of infrastructure is a new XRD, Composition, and Scaffolder template — the Backstage UI a developer already knows doesn't change shape.

Who owns what in the stack

LayerOwnsConcrete example
BackstageDeveloper experience: catalog, docs, self-service intakeScaffolder template, TechDocs page
Git repositorySource of truth, review, and audit trailPR containing the Crossplane claim
GitOps controllerContinuous reconciliation from Git to clusterArgo CD auto-sync, drift detection, self-heal
Kubernetes API serverUniversal control-plane interface, RBAC, admissionCRDs for every claim and managed resource
CrossplaneTranslates declarative intent into real infrastructureXRD, Composition, managed resource
Cloud provider APIsThe actual infrastructureRDS instance, S3 bucket, IAM role

The most common mistake is letting developers edit or apply Crossplane claims directly instead of only through the Scaffolder template. The moment someone can hand-write a claim and kubectl apply it, every guardrail the Composition was supposed to enforce becomes optional. Keep cluster write access limited to the GitOps controller, full stop.

Key takeaways

  • Backstage owns the developer-facing request; Crossplane owns turning that request into real infrastructure; GitOps is the accountable hinge between them.
  • Crossplane's XRDs and Compositions let a platform team define a simplified, guarded API that hides the real resource graph from developers.
  • GitOps makes every infrastructure change a reviewable pull request, gives you drift correction for free, and turns rollback into git revert.
  • Guardrails belong in the Composition schema, not in developer discipline — if an option is unsafe, don't expose it.
  • Never let developers apply claims directly to the cluster; the Scaffolder template and the GitOps controller should be the only paths in.

Quick answers

Do application developers need to learn Kubernetes to use this?

No. They fill out a form in Backstage. Kubernetes, Crossplane, and the GitOps controller are implementation details of the golden path, not something the requester ever has to understand or touch.

Does Crossplane replace Terraform?

Not usually. Most platforms run both: Terraform for foundational infrastructure that changes rarely (VPCs, account boundaries, IAM guardrails), and Crossplane for the day-2 self-service layer developers request often — because Crossplane resources are native Kubernetes objects that reconcile continuously, instead of running on a scheduled apply.

Can I use something other than Backstage as the front end?

Yes. Crossplane just exposes a Kubernetes API — Backstage is the most common CNCF-graduated choice, but the same claims work behind a plain PR-based workflow, a commercial IDP like Port or Humanitec, or a custom internal UI.

Best next step

Pick one infrastructure request your team gets most often — a database, a queue, a namespace with sane defaults — and build the full loop for that one thing first: XRD, Composition, Scaffolder template, GitOps sync. Prove the golden path end to end before generalizing it into a platform.

Reach out if you want to talk through a Crossplane and Backstage rollout