Building an Internal Developer Platform with Backstage and Kubernetes
TL;DR — A useful IDP is three things: a catalog, a scaffolder, and a real backing automation. Don’t build all the plugins. Don’t fork Backstage. Ship the catalog + one golden path in 90 days, measure adoption, then expand.
If you read last week’s framing piece, the question I keep getting is the same one: “OK, we’re sold. Where do we actually start?” The answer most teams jump to is “let’s spin up Backstage.” That’s fine. The trap is then spending six months on Backstage plumbing and never shipping a single golden path. I’ve watched this happen at three companies now. Don’t.
This post walks through a minimum viable IDP on Backstage 1.21 and Kubernetes 1.29. The goal is to have something real in production within 90 days that one stream-aligned team will actually use. Anything beyond that is bonus.
The three pieces that matter
Strip away the marketing slides. An IDP is three things that have to work together:
- A catalog. A canonical inventory of services, owners, dependencies, and metadata. Without this, every other layer is guessing.
- A scaffolder. A way to provision new services that materializes everything in the catalog automatically, plus the actual infrastructure underneath.
- The backing automation. What the catalog and scaffolder point at: Kubernetes manifests, GitOps controllers, cloud resources. Backstage is the cover. The automation is the engine.
Most failed IDP rollouts I’ve seen invested heavily in #1 and #2 and skipped #3. The result is a beautiful portal pointing at services nobody can actually deploy.
The Backstage starting point
Backstage 1.21 (early January 2024) is finally stable enough that I’d recommend create-app as the entry point rather than building from scratch. The new backend system (1.18+) is a real improvement and the migration story for older deployments is reasonable.
# Backstage 1.21.x, January 2024
npx @backstage/create-app@latest
cd <your-app>
yarn install
yarn dev
The default app gives you the catalog, the scaffolder, the TechDocs system, and a placeholder Kubernetes plugin. That’s most of what a 90-day MVP needs.
The one customization I always make on day one is wiring the catalog to a Git-backed source of truth rather than relying on the UI to register things. The catalog is the foundation. It needs to be reviewable, diffable, and recoverable.
# app-config.yaml — Backstage 1.21
catalog:
rules:
- allow: [Component, System, API, Resource, Location, Group, User]
locations:
- type: url
target: https://github.com/acme/platform-catalog/blob/main/catalog-info.yaml
rules:
- allow: [Location]
providers:
githubOrg:
default:
id: acme
orgUrl: https://github.com/acme
userIngestionDelta: 60m
The Location entity at that URL points at a list of other locations — one per repo containing a catalog-info.yaml. New services register themselves by adding a catalog-info.yaml to their repo. The scaffolder will do this automatically.
The catalog model
You can model anything in Backstage. Resist the urge. A 90-day MVP needs four entity kinds and that’s it:
- Component — a deployable unit (service, library, frontend app)
- API — a contract between components (OpenAPI or AsyncAPI spec)
- System — a logical grouping of components owned by one team
- Group — owning team
Skip Resource, Domain, and custom kinds for now. You can add them once you have real usage data telling you why.
Each component needs at minimum:
# catalog-info.yaml — committed to the service repo
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: orders-api
description: Orders domain API
annotations:
backstage.io/kubernetes-id: orders-api
github.com/project-slug: acme/orders-api
spec:
type: service
lifecycle: production
owner: group:default/orders-team
system: orders
providesApis:
- orders-api-v1
The kubernetes-id annotation is what wires the Kubernetes plugin to your cluster, which is the next piece.
Wiring Kubernetes 1.29
The Kubernetes plugin in Backstage looks at labels on your workloads. The convention is backstage.io/kubernetes-id: <component-name>. Set that label on every Deployment, StatefulSet, Service, and Ingress for a component and Backstage will surface them on the component’s page.
# deployment.yaml — Kubernetes 1.29
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
labels:
backstage.io/kubernetes-id: orders-api
app.kubernetes.io/name: orders-api
app.kubernetes.io/part-of: orders
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: orders-api
template:
metadata:
labels:
app.kubernetes.io/name: orders-api
backstage.io/kubernetes-id: orders-api
spec:
containers:
- name: orders-api
image: ghcr.io/acme/orders-api:v0.4.2
ports:
- containerPort: 8080
Backstage talks to the cluster through a service account with read-only RBAC. The Backstage Kubernetes plugin docs have the exact ClusterRole. Don’t grant more than get/list/watch on standard workload kinds. The platform should not have write access from the portal in v1.
The first golden path
One template. Not five. Pick the most common service shape on your engineering team and template that.
For most backend orgs I’ve worked with, this is a Go HTTP API with a Postgres dependency. The template should produce:
- A new repo with
main.go,Dockerfile,README.md, and acatalog-info.yaml - A GitHub Actions workflow that builds, tests, and pushes a container image
- Kubernetes manifests in a separate
gitopsrepo - A new Argo CD
Applicationpointing at the manifests - A row in the catalog after a short delay
# go-api-template.yaml — Backstage 1.21
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: go-api-service
title: Go API Service
spec:
owner: platform-team
type: service
parameters:
- title: Service info
required: [name, owner, system]
properties:
name: { type: string, pattern: '^[a-z][a-z0-9-]+$' }
owner: { type: string, ui:field: OwnerPicker }
system: { type: string }
steps:
- id: fetch-base
action: fetch:template
input:
url: ./skeleton
values: { name: ${{ parameters.name }}, owner: ${{ parameters.owner }} }
- id: publish
action: publish:github
input:
repoUrl: github.com?repo=${{ parameters.name }}&owner=acme
defaultBranch: main
- id: gitops-pr
action: github:repo:create-pr
input:
repoUrl: github.com?repo=gitops&owner=acme
title: 'Add ${{ parameters.name }}'
branchName: scaffold-${{ parameters.name }}
targetPath: 'apps/${{ parameters.name }}'
- id: register
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
The first template you ship will be wrong. That’s fine. Treat it like a product release: ship, gather feedback for two weeks, iterate.
What to defer
In your first 90 days, don’t build:
- Cost dashboards — bolt on later via Vantage, Kubecost, or a custom plugin
- Multi-cluster federation — one cluster works for the pilot
- SSO and fine-grained RBAC inside Backstage — use the basic GitHub auth provider
- Custom plugins for vendor X — almost certainly someone else already wrote it
- A second golden path — finish the first one
The temptation to build “the right way” from day one will lose you 60 days easily. Resist.
Common Pitfalls
- Forking Backstage. You will be tempted. Don’t. Use plugins and extensions. A fork is a maintenance tax forever.
- Catalog without ownership. A
Componentwithowner: unknownis worse than no entry. Enforce a valid owner via a catalog validator before merge. - The “one big repo” catalog. Distributing
catalog-info.yamlfiles into the service repos themselves is harder to set up initially but pays off massively. Centralized catalogs rot fast. - Letting the scaffolder write to production directly. All scaffolder steps should produce PRs into a GitOps repo. Humans (or automated merges) ship to production. Keeps the audit trail intact.
- Ignoring TechDocs. The MkDocs-backed docs system is one of Backstage’s best features. Wire it from day one — every component gets a
/docspage fromdocs/in its repo.
The pitfall I see most often: the platform team treats Backstage as the product. Backstage is the front door. The product is the experience of going from idea to production. If the front door is pretty but the path behind it is broken, you’ve shipped nothing.
Wrapping Up
A 90-day MVP looks like this: Backstage 1.21 with the default plugins, a catalog backed by GitHub, the Kubernetes plugin wired to one cluster, and one Go API golden path that ships a service to production. That’s enough to start measuring adoption. Everything else gets prioritized by what your first paying customer asks for.
Next post in this series digs into golden paths — what makes them sticky versus what makes them shelfware. There’s more to it than a slick scaffolder template.