background-shape
Building an Internal Developer Platform with Backstage and Kubernetes
January 8, 2024 · 6 min read · by Muhammad Amal programming

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:

  1. A catalog. A canonical inventory of services, owners, dependencies, and metadata. Without this, every other layer is guessing.
  2. A scaffolder. A way to provision new services that materializes everything in the catalog automatically, plus the actual infrastructure underneath.
  3. 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 a catalog-info.yaml
  • A GitHub Actions workflow that builds, tests, and pushes a container image
  • Kubernetes manifests in a separate gitops repo
  • A new Argo CD Application pointing 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 Component with owner: unknown is worse than no entry. Enforce a valid owner via a catalog validator before merge.
  • The “one big repo” catalog. Distributing catalog-info.yaml files 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 /docs page from docs/ 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.