Containerization Lesson 78 of 113

Build a Backstage Developer Portal with the Kubernetes and TechDocs Plugins

In a nutshell

Backstage is an open-source developer portal — a single website where every engineer in a company finds their services, reads their docs, and kicks off routine tasks without filing a ticket. Spotify built it to tame its own microservice sprawl, open-sourced it in 2020, and donated it to the CNCF, where it is now an incubating project. Thousands of companies run it today as the front door to their internal platform.

Picture the sprawl a newcomer walks into: hundreds of services, dozens of repos, docs scattered across wikis nobody trusts, and every “who owns this?” answered in Slack. Backstage collapses that into one place. Think of it as three things fused together:

Everything else — showing live Kubernetes status, CI/CD, security scans — is a plugin bolted onto that spine. Backstage is deliberately the portal over your platform, not the platform itself: it does not run your clusters, it gives humans one pane of glass onto them.

This particular lesson is a full production build, so it is advanced — but the mental model above is all a newcomer needs to follow along. Skim the concepts, then watch each build step turn one pillar on.

Level: Advanced · Time: ~40 min

After this you can:

A fintech platform team owns 180 microservices across four Kubernetes clusters, and the daily tax is real: a new engineer spends a week just learning which repo owns which service, on-call wakes up and cannot remember whether payments-ledger runs in prod-eu or prod-us, docs live in seventeen wikis that nobody updates, and every “who owns this?” question routes through a Slack channel and two senior engineers. The mandate from the head of platform is concrete: one portal where every service is catalogued with its owner, its live cluster status is one click away, and its docs are versioned next to its code. This guide builds exactly that — a production Backstage instance with the Kubernetes and TechDocs plugins — and wires it into the identity, secrets, CI/CD, security, and observability stack the team already runs, so the portal is something the security team signs and on-call actually trusts.

Prerequisites

Target topology

Build a Backstage Developer Portal with the Kubernetes and TechDocs Plugins — topology

The portal is a single Node.js application (backend + bundled frontend) running on the platform cluster. Engineers reach it through Akamai at the edge for TLS termination, global anycast, and WAF/bot protection, then NGINX ingress. They authenticate via Okta (OIDC), and Backstage maps the Okta identity to a catalog User/Group so ownership and access are first-class. The catalog is populated from GitHub by discovering catalog-info.yaml files in every repo. The Kubernetes plugin in the backend talks to each of the four workload clusters through a read-only ServiceAccount token (held in Vault, injected at runtime) to render live pod, deployment, and ingress status per service. TechDocs builds each repo’s Markdown into a static site at CI time and publishes it to an object-store bucket that the portal serves. Secrets never sit in the pod spec — the Vault Agent sidecar leases them. CrowdStrike Falcon runs on the cluster nodes for runtime threat detection, Wiz (with Wiz Code) scans the cluster posture and the portal repo/IaC for misconfigurations, Dynatrace instruments the portal and clusters for tracing and golden signals, and ServiceNow receives a change ticket whenever a new component is onboarded to the catalog.

The build order below matters: stand up a bare portal first, prove auth, then add the catalog, then Kubernetes, then TechDocs. Adding plugins to a portal that is not yet authenticating is how you waste a day debugging the wrong layer.

The three pillars: catalog, docs, and golden paths

Before the build, hold the model of what Backstage actually is under the UI. Strip away the plugins and Backstage is a software catalog — a graph of entities describing your systems — plus a plugin runtime that renders views over that graph. Almost everything you do begins by getting an entity into the catalog.

The software catalog and catalog-info.yaml. Every “thing” in Backstage is an entity, declared in a small YAML file (conventionally catalog-info.yaml) that lives in the thing’s own repo. The kinds you meet first:

Kind What it models Example
Component A piece of software you build and run — a service, website, or library payments-ledger
API A network interface a Component exposes or consumes (OpenAPI, gRPC, GraphQL) payments-api
Resource Infrastructure a Component depends on — a database, bucket, or topic ledger-postgres
System A set of Components and Resources that work together settlement
Domain A business area that owns several Systems payments
Group / User Teams and people — the targets of owner payments-team

Because ownership and grouping are entities, not free text, the portal can answer “what does the payments team own?” or “what depends on this database?” as graph queries rather than tribal knowledge. The catalog-info.yaml in Step 4 is a Component; a System that ties several together looks like this:

apiVersion: backstage.io/v1alpha1
kind: System
metadata:
  name: settlement
  description: Settlement and reconciliation platform
spec:
  owner: group:default/payments-team
  domain: payments

TechDocs = docs-as-code. TechDocs is not a wiki. Engineers write Markdown in a docs/ folder in the service repo, describe the nav in an mkdocs.yml, and TechDocs renders it as a versioned site inside the portal, on the same page as the service. Because the docs live with the code, they version, review, and ship with it — a runbook edit rides the same pull request as the fix it documents. That is the cure for the “seventeen stale wikis” problem, and it is why Step 6 matters as much as the code.

Scaffolder templates = golden paths. The scaffolder is the self-service half of Backstage, and the one this particular build does not turn on but every real platform eventually does. A software template (kind: Template) presents a form, then runs a series of actions — fetch a skeleton repo, render it with the user’s answers, push it to GitHub, register it in the catalog, open a CI pipeline. The result: an engineer clicks “Create”, answers three questions, and two minutes later has a new service that already has an owner, a catalog-info.yaml, docs, a pipeline, and guardrails — the golden path, paved. We cover its anatomy in “Going deeper”.

The Kubernetes plugin. This is the plugin the lesson features. It has two halves — a frontend tab on each entity page and a backend that holds cluster credentials and queries the API servers — and it maps a catalog Component to its live workloads by a label selector (Step 5). It is a pure read surface: it never mutates a cluster.

Plugins and the backend. Backstage ships a thin core; features arrive as plugins. Frontend plugins are React; backend plugins run in the Node backend. Modern Backstage wires the backend with the new backend system — the one-liner backend.add(import('@backstage/plugin-kubernetes-backend')) you will use in Step 5 — which replaced a verbose per-plugin wiring style and is the default create-app produces today. Grasping that a “plugin” has a frontend part, a backend part, or both explains why installs so often touch two package.json files.

1. Scaffold and run the portal locally

Create the app from the official template, then run it once locally against SQLite to confirm the toolchain works before touching the cluster.

# Scaffold (pins the current Backstage release line)
npx @backstage/create-app@latest --path kloudvin-portal
cd kloudvin-portal

# Install and run both backend + frontend
yarn install --immutable
yarn dev

yarn dev serves the frontend on http://localhost:3000 and the backend on :7007. You should see the default catalog with the example components. Stop it (Ctrl-C) once it renders — local SQLite and the guest identity provider are for the smoke test only; everything below moves to PostgreSQL and real SSO.

Pin your versions so CI is reproducible:

yarn backstage-cli versions:bump   # aligns all @backstage/* packages to one release

2. Wire PostgreSQL and externalize config

Backstage reads app-config.yaml (base) and app-config.production.yaml (overlay). Move the database to PostgreSQL and pull every secret from the environment so Vault can supply it. Edit app-config.production.yaml:

app:
  baseUrl: https://portal.kloudvin.io
backend:
  baseUrl: https://portal.kloudvin.io
  listen:
    port: 7007
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: 5432
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
      ssl:
        rejectUnauthorized: true

Note the ${...} indirection — Backstage substitutes environment variables at boot. Those variables are populated by the Vault Agent sidecar (Step 8), never hard-coded. Add the pg driver:

yarn --cwd packages/backend add pg

3. Configure Okta SSO (OIDC)

Replace the guest provider with real SSO. In Okta, create an OIDC web app with redirect URI https://portal.kloudvin.io/api/auth/okta/handler/frame, and capture the client ID/secret. Where the org also needs Azure RBAC, Okta is federated to Entra ID, but the portal itself trusts Okta as the OIDC issuer. Add the resolver to app-config.production.yaml:

auth:
  environment: production
  providers:
    okta:
      production:
        clientId: ${OKTA_CLIENT_ID}
        clientSecret: ${OKTA_CLIENT_SECRET}
        audience: ${OKTA_AUDIENCE}        # https://<your-org>.okta.com
        signIn:
          resolvers:
            - resolver: emailMatchingUserEntityProfileEmail

The resolver maps the authenticated Okta email to a catalog User entity, which is what makes ownership and access checks resolve to a real person. Add the sign-in page wiring in packages/app/src/App.tsx:

import { oktaAuthApiRef } from '@backstage/core-plugin-api';

const app = createApp({
  components: {
    SignInPage: props => (
      <SignInPage {...props} auto provider={{
        id: 'okta-auth-provider',
        title: 'Okta',
        message: 'Sign in with your KloudVin Okta account',
        apiRef: oktaAuthApiRef,
      }} />
    ),
  },
  // ...
});

4. Populate the service catalog from GitHub

The catalog is the spine of the portal. Use discovery so any repo containing a catalog-info.yaml is ingested automatically — no central list to maintain. Add a GitHub integration and a discovery processor to app-config.production.yaml:

integrations:
  github:
    - host: github.com
      token: ${GITHUB_TOKEN}            # read-only PAT or GitHub App, from Vault

catalog:
  providers:
    github:
      kloudvinOrg:
        organization: 'kloudvin'
        catalogPath: '/catalog-info.yaml'
        filters:
          branch: 'main'
        schedule:
          frequency: { minutes: 30 }
          timeout: { minutes: 3 }
  rules:
    - allow: [Component, System, API, Resource, Location, User, Group]

A service’s catalog-info.yaml (committed to its own repo) declares identity, owner, and — critically for Step 5 — the label that links it to its Kubernetes workloads:

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: payments-ledger
  description: Double-entry ledger for settlement
  annotations:
    github.com/project-slug: kloudvin/payments-ledger
    backstage.io/kubernetes-label-selector: 'app=payments-ledger'
    backstage.io/techdocs-ref: dir:.
spec:
  type: service
  lifecycle: production
  owner: group:default/payments-team
  system: settlement

Group and user entities can be ingested from Okta/Entra via the org plugin, or committed as YAML; either way ownership resolves to a real team, which is what kills the “who owns this?” Slack thread.

5. Install and wire the Kubernetes plugin

This is the plugin that puts live cluster status next to each service. It has two halves: a backend that holds cluster credentials and queries the API servers, and a frontend tab on the entity page.

Install the packages:

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

Register the backend in packages/backend/src/index.ts:

backend.add(import('@backstage/plugin-kubernetes-backend'));

Add the entity tab in packages/app/src/components/catalog/EntityPage.tsx:

import { EntityKubernetesContent } from '@backstage/plugin-kubernetes';

// inside the service entity layout:
<EntityLayout.Route path="/kubernetes" title="Kubernetes">
  <EntityKubernetesContent refreshIntervalMs={30000} />
</EntityLayout.Route>

Now declare the four clusters in app-config.production.yaml. Use serviceAccount auth with a read-only token per cluster — Backstage must never hold write credentials:

kubernetes:
  serviceLocatorMethod:
    type: multiTenant
  clusterLocatorMethods:
    - type: config
      clusters:
        - name: prod-eu
          url: https://prod-eu.k8s.kloudvin.io:6443
          authProvider: serviceAccount
          serviceAccountToken: ${K8S_TOKEN_PROD_EU}
          caData: ${K8S_CA_PROD_EU}
          skipTLSVerify: false
        - name: prod-us
          url: https://prod-us.k8s.kloudvin.io:6443
          authProvider: serviceAccount
          serviceAccountToken: ${K8S_TOKEN_PROD_US}
          caData: ${K8S_CA_PROD_US}
        - name: staging
          url: https://staging.k8s.kloudvin.io:6443
          authProvider: serviceAccount
          serviceAccountToken: ${K8S_TOKEN_STAGING}
          caData: ${K8S_CA_STAGING}

Create the read-only ServiceAccount on each workload cluster. Apply this with Argo CD so the RBAC is GitOps-managed and identical across clusters:

# backstage-reader.yaml — applied to every workload cluster
apiVersion: v1
kind: ServiceAccount
metadata:
  name: backstage-reader
  namespace: backstage-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: backstage-reader
rules:
  - apiGroups: ['']
    resources: ['pods', 'services', 'configmaps', 'limitranges']
    verbs: ['get', 'list', 'watch']
  - apiGroups: ['apps']
    resources: ['deployments', 'replicasets', 'statefulsets', 'daemonsets']
    verbs: ['get', 'list', 'watch']
  - apiGroups: ['networking.k8s.io']
    resources: ['ingresses']
    verbs: ['get', 'list', 'watch']
  - apiGroups: ['autoscaling']
    resources: ['horizontalpodautoscalers']
    verbs: ['get', 'list', 'watch']
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: backstage-reader
subjects:
  - kind: ServiceAccount
    name: backstage-reader
    namespace: backstage-system
roleRef:
  kind: ClusterRole
  name: backstage-reader
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret
metadata:
  name: backstage-reader-token
  namespace: backstage-system
  annotations:
    kubernetes.io/service-account.name: backstage-reader
type: kubernetes.io/service-account-token

Pull each cluster’s token and CA into Vault — never into the repo:

TOKEN=$(kubectl -n backstage-system get secret backstage-reader-token \
  -o jsonpath='{.data.token}' | base64 -d)
CA=$(kubectl -n backstage-system get secret backstage-reader-token \
  -o jsonpath='{.data.ca\.crt}')   # already base64 for caData

vault kv put secret/backstage/k8s/prod-eu token="$TOKEN" ca="$CA"

The label selector you set in Step 4 (app=payments-ledger) is how the plugin maps a catalog component to its workloads. Make sure your Deployments and Pods actually carry that label, or the Kubernetes tab renders empty — the single most common “it doesn’t work” report. (Labels and selectors are worth a firm grip; see Kubernetes Labels, Selectors, Annotations & Field Selectors.) The read-only ClusterRole above is least-privilege RBAC in miniature — the full design pattern is in Kubernetes RBAC & Service Accounts.

6. Enable TechDocs with an external builder

TechDocs turns each repo’s Markdown into a versioned docs site shown inside the portal. The mistake to avoid is the default local builder, which compiles docs inside the running portal pod — slow, and it needs Python/MkDocs in your runtime image. Use the external generator: CI builds the static site and publishes it to a bucket; the portal only serves it. Configure app-config.production.yaml:

techdocs:
  builder: 'external'          # CI builds; portal never compiles at runtime
  generator:
    runIn: 'local'
  publisher:
    type: 'awsS3'              # or googleGcs / azureBlobStorage
    awsS3:
      bucketName: 'kloudvin-techdocs'
      region: 'eu-west-1'

Each repo needs an mkdocs.yml at its root and docs under docs/:

# mkdocs.yml
site_name: 'payments-ledger'
plugins:
  - techdocs-core
nav:
  - Home: index.md
  - Runbook: runbook.md
  - API: api.md

The backstage.io/techdocs-ref: dir:. annotation from Step 4 tells the portal where to find the published site for that component. Engineers now read a service’s runbook in the same tab as its live cluster status — docs versioned with code, the seventeen-wiki problem solved.

7. Build the portal image and push via CI

Build the production image. The standard backend Dockerfile bundles the compiled frontend:

yarn build:backend                          # compiles backend + bundles frontend
docker build . -f packages/backend/Dockerfile --tag kloudvin/portal:$(git rev-parse --short HEAD)

In GitHub Actions, run lint/test/build, then have Wiz Code scan the image and IaC for vulnerabilities and misconfigurations before it can be promoted — a failing scan blocks the merge:

# .github/workflows/portal.yml
name: portal
on: { push: { branches: [main] } }
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: yarn install --immutable
      - run: yarn tsc && yarn lint:all && yarn test:all
      - run: yarn build:backend
      - name: Wiz Code IaC + image scan
        run: wizcli iac scan --path ./ && wizcli docker scan --image kloudvin/portal:${{ github.sha }}
      - name: Build & push
        run: |
          docker build . -f packages/backend/Dockerfile -t kloudvin/portal:${{ github.sha }}
          docker push kloudvin/portal:${{ github.sha }}

The TechDocs build also lives in CI — each service repo runs npx @techdocs/cli generate && npx @techdocs/cli publish --publisher-type awsS3 --storage-name kloudvin-techdocs --entity <ns/kind/name> so docs publish on every merge.

8. Deploy to the cluster with Vault-injected secrets

Deliver the portal with Argo CD pointing at a Helm chart or plain manifests in Git. Secrets come from Vault via the agent injector — the Deployment carries annotations, not credentials:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backstage-portal
  namespace: backstage-system
spec:
  replicas: 2
  selector: { matchLabels: { app: backstage-portal } }
  template:
    metadata:
      labels: { app: backstage-portal }
      annotations:
        vault.hashicorp.com/agent-inject: 'true'
        vault.hashicorp.com/role: 'backstage'
        vault.hashicorp.com/agent-inject-secret-env: 'secret/data/backstage/app'
        vault.hashicorp.com/agent-inject-template-env: |
          {{- with secret "secret/data/backstage/app" -}}
          export POSTGRES_PASSWORD="{{ .Data.data.pg_password }}"
          export GITHUB_TOKEN="{{ .Data.data.github_token }}"
          export OKTA_CLIENT_SECRET="{{ .Data.data.okta_secret }}"
          {{- end }}
    spec:
      serviceAccountName: backstage
      containers:
        - name: backstage
          image: kloudvin/portal:GITSHA
          args: ['node', 'packages/backend', '--config', 'app-config.yaml', '--config', 'app-config.production.yaml']
          command: ['/bin/sh', '-c', '. /vault/secrets/env && exec node packages/backend ...']
          ports: [{ containerPort: 7007 }]

Expose it behind NGINX ingress with cert-manager TLS, and point Akamai at the ingress as origin:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: backstage-portal
  namespace: backstage-system
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: '10m'
spec:
  ingressClassName: nginx
  tls:
    - hosts: [portal.kloudvin.io]
      secretName: portal-tls
  rules:
    - host: portal.kloudvin.io
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: backstage-portal, port: { number: 7007 } } }

When a new component is onboarded to the catalog, fire a ServiceNow change record from the onboarding pipeline so platform changes have an auditable trail, and let Dynatrace OneAgent (already on the node pool) trace the portal’s requests and surface golden signals. Vault-injected secrets like these are covered end to end in Configure Vault JWT/OIDC and Kubernetes Auth for Secretless Workload Access.

Validation

Confirm each layer end to end, in order:

# 1. Pod is healthy and serving
kubectl -n backstage-system get pods -l app=backstage-portal
kubectl -n backstage-system port-forward deploy/backstage-portal 7007:7007 &
curl -fsS http://localhost:7007/healthcheck && echo OK

# 2. Catalog discovery ingested real services (not the examples)
curl -fsS http://localhost:7007/api/catalog/entities?filter=kind=component \
  -H "Authorization: Bearer $TOKEN" | jq '.[].metadata.name' | head

# 3. Kubernetes plugin can reach a cluster
kubectl --token="$TOKEN_PROD_EU" --server=https://prod-eu.k8s.kloudvin.io:6443 \
  --certificate-authority=<(echo "$CA" | base64 -d) auth can-i list pods -A   # -> yes

Then in the browser: sign in via Okta, open payments-ledger, confirm the Kubernetes tab shows live pods/deployments from prod-eu, and the Docs tab renders the published runbook. If the Kubernetes tab is empty, the label selector and the workload labels disagree (see Step 5); if Docs 404s, the CI publish step did not run for that entity.

Rollback / teardown

Because delivery is GitOps and secrets are external, rollback is clean:

# Roll the portal back to the previous known-good image via Argo CD
argocd app rollback backstage-portal       # pick the prior revision
# or pin the manifest back and let Argo sync

# Full teardown of the portal (clusters/services untouched)
kubectl delete -f manifests/backstage-portal/        # Deployment, Service, Ingress
kubectl -n backstage-system delete secret portal-tls

# Remove the read-only reader from EACH workload cluster
kubectl delete -f backstage-reader.yaml --context prod-eu
kubectl delete -f backstage-reader.yaml --context prod-us
kubectl delete -f backstage-reader.yaml --context staging

# Revoke the Vault secrets the portal used
vault kv metadata delete secret/backstage/app
vault kv metadata delete secret/backstage/k8s/prod-eu

The PostgreSQL database and the TechDocs bucket persist; drop them explicitly only if you are decommissioning, since they hold catalog history and built docs.

Common pitfalls

Security notes

Identity is the gate: engineers reach the portal only through Okta SSO (federated to Entra ID where Azure RBAC is also in play), so there is no anonymous access and group claims drive what each team sees. The portal holds only read-only Kubernetes credentials, scoped by the ClusterRole above, so a compromised portal cannot change a cluster. Every secret — DB password, GitHub token, OIDC client secret, per-cluster tokens — lives in HashiCorp Vault and is leased into the pod by the Vault Agent sidecar, never written to a manifest or image. Wiz continuously scans the cluster’s posture and flags drift (a public service, an over-broad RBAC binding), while Wiz Code gates the portal repo and IaC in CI so a misconfiguration is caught before merge. CrowdStrike Falcon sensors on the node pool give runtime threat detection on the portal and its neighbors, feeding the SOC. Terminate TLS and apply WAF/bot rules at Akamai so the public edge is hardened before traffic reaches NGINX.

Cost notes

Backstage itself is open source; the spend is the infrastructure under it. The portal is light — two small replicas (≈0.5 vCPU / 1 GiB each) comfortably serve a few thousand engineers, so right-size the requests rather than over-provisioning. PostgreSQL is the one stateful dependency: a small managed instance (single-AZ for non-prod, multi-AZ for prod) is enough; the catalog is metadata, not bulk data. TechDocs storage is cheap static HTML in an object-store bucket — pennies — but watch egress if docs are heavily read; front the bucket with the CDN you already pay for. Keep the Kubernetes plugin’s refresh interval sane (30s, as configured) so you are not hammering four API servers from every open browser tab. The real return is not a line on the cloud bill — it is the week of onboarding and the nightly “where does this run?” pages you stop paying for.

Going deeper

The eight steps stand up a working portal. This section is for the reader who now owns it — the internals, the extension seams, and the failure modes that only show at scale.

The catalog processing loop: providers, processors, and relations

The catalog is not a database you write to; it is a processing loop you feed sources into. Two roles do the work. An entity provider is the source — it discovers entities and pushes their locations into the catalog on a schedule. GithubEntityProvider (the catalog.providers.github block in Step 4) is one; GithubOrgEntityProvider ingests teams and users as Group/User. A processor then runs over each entity in the loop: it fetches the location, parses the YAML, validates it against the entity schema, resolves placeholders, and emits two things — more locations to process, and relations between entities.

Those relations are the payoff. From the catalog-info.yaml fields, the loop derives a graph:

The dependency graphs and “owned by” filters in the UI are queries over that graph — which is why modelling System, API, and Resource (not just Component) is what turns a flat list into a portal. One consequence to internalise: the catalog is eventually consistent. Edit a catalog-info.yaml and the change lands on the next processing pass (the 30-minute schedule in Step 4), not on save — so “I changed the owner and the portal still shows the old one” is usually just the loop not having run yet.

The TechDocs pipeline, end to end

TechDocs is four stages: generator → build → storage → serve. With builder: external, CI does the first three. techdocs-cli generate invokes MkDocs with the techdocs-core theme to turn docs/*.md into a static bundle — HTML, a techdocs_metadata.json, and a search index. techdocs-cli publish uploads that bundle to the object store under an entity-namespaced path (<namespace>/<kind>/<name>/). At read time the portal’s TechDocs backend fetches the pre-built bundle from the bucket and serves it — no MkDocs, no Python in the portal image, no build latency on the request.

Contrast builder: local, which runs generate inside the portal pod the first time a doc is opened: fine on a laptop, a latency-and-dependency trap in production. Two annotations glue it together — backstage.io/techdocs-ref tells the backend where the source is (usually dir:.), and the publisher path tells it where the built site landed. A Docs tab that 404s almost always means the publish step never ran for that entity, not that the portal is broken.

Scaffolder actions and custom golden paths

A software template is a form plus a pipeline. Here is the anatomy the build skipped:

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: golden-path-service
  title: Golden Path Microservice
  description: Create a Node service with owner, docs, CI, and a catalog entry
  tags:
    - recommended
    - node
spec:
  owner: group:default/platform-team
  type: service
  parameters:
    - title: Service details
      required:
        - name
        - owner
      properties:
        name:
          title: Name
          type: string
          description: Unique service name
        owner:
          title: Owner
          type: string
          ui:field: OwnerPicker
          ui:options:
            catalogFilter:
              kind: Group
  steps:
    - id: fetch
      name: Fetch skeleton
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}
    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        repoUrl: github.com?owner=kloudvin&repo=${{ parameters.name }}
        defaultBranch: main
    - id: register
      name: Register in catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml
  output:
    links:
      - title: Repository
        url: ${{ steps.publish.output.remoteUrl }}
      - title: Open in catalog
        icon: catalog
        entityRef: ${{ steps.register.output.entityRef }}

parameters is a JSON-schema form, enriched by field extensions like OwnerPicker (backed by real Group entities) and RepoUrlPicker. steps run built-in actionsfetch:template renders a skeleton with the user’s answers, publish:github creates the repo, catalog:register wires it into the catalog. You can register custom actions (a backend module that, say, opens a Jira ticket or provisions a database) to extend the pipeline. This is where “self-service platform” becomes real: the template encodes the golden path so every new service is born compliant — an owner, a catalog-info.yaml, docs, and a pipeline, on commit one. The guardrail rule: a template with no required owner and no downstream policy just industrialises non-compliant services. Pair it with an owner picker backed by real Groups and with admission policy (Kyverno/Gatekeeper) on the target cluster so the generated workload cannot skip the rules.

Plugin architecture and the new backend system

A Backstage plugin can have a frontend part (React — createPlugin, routable extensions, the EntityLayout.Route you added in Step 5), a backend part (Node), or both. Modern backends use the new backend system: createBackend() plus backend.add(import('...')), with @backstage/backend-defaults supplying wiring and dependency injection via service references. It replaced the old style — a hand-written PluginEnvironment and a createRouter per plugin — which was boilerplate-heavy and hard to share. The new system is the default create-app emits today, and the old one is deprecated; a lot of “this plugin’s install docs don’t match my repo” confusion is a plugin still documenting the legacy wiring.

The important extension seam for a platform team is the backend module: a package that adds to an existing plugin — a new auth provider, a custom catalog processor, an extra scaffolder action — without forking it. That is how you extend the catalog or scaffolder to fit your org and still take upstream upgrades.

Auth, identity, and the permission framework

Auth is two moves. An auth provider performs the sign-in (Okta, in Step 3). A sign-in resolver then maps that external identity to a catalog User, producing a Backstage identity carrying ownershipEntityRefs — the user plus every Group they belong to. That identity is what makes “owned by me” mean something, and it is the input to the permission framework.

Core Backstage ships the framework but no policy: by default everything is allowed. You supply a PermissionPolicy whose handle() returns a decision — ALLOW, DENY, or a conditional decision evaluated against the entity:

// packages/backend — a permission policy module (representative)
class KloudvinPolicy implements PermissionPolicy {
  async handle(request: PolicyQuery, user?: BackstageIdentityResponse): Promise<PolicyDecision> {
    if (isPermission(request.permission, catalogEntityDeletePermission)) {
      return createCatalogConditionalDecision(request.permission, {
        rule: 'IS_ENTITY_OWNER',
        resourceType: 'catalog-entity',
        params: { claims: user?.identity.ownershipEntityRefs ?? [] },
      });
    }
    return { result: AuthorizeResult.ALLOW };
  }
}

That policy says “anyone may read, but only an owner may delete.” For teams that want roles managed in a UI rather than in code, the community RBAC plugin (@backstage-community/plugin-rbac, from the Red Hat Developer Hub lineage) layers role-and-permission administration on top of the framework. The takeaway: allow-all is fine for a pilot, but the moment the scaffolder can create infrastructure, an explicit policy is not optional.

CI/CD, GitOps, and multi-cluster Kubernetes

The Kubernetes plugin fronts many clusters from one portal: serviceLocatorMethod: multiTenant fans a query across all configured clusters, and clusterLocatorMethods lists them. This build uses type: config with per-cluster serviceAccount tokens; other locators (catalog, gke) and auth providers (aws, google, azure, oidc) let the plugin borrow cloud-native identity instead of static tokens — worth it once you are past a handful of clusters.

GitOps is what keeps the moving parts honest. The reader ServiceAccount (Step 5) and the portal Deployment (Step 8) are both delivered by Argo CD or by Flux, so the reader RBAC is byte-identical across all four clusters and the portal image rolls forward and back declaratively. And the portal closes the loop on delivery itself: add the GitHub Actions, Argo CD, or Flux plugins and each entity page shows that service’s pipeline runs and sync status, so “is my change live?” is answered in the same pane as “who owns this?”.

Where Backstage sits: platform engineering and the IDP

Step back to the strategy. An Internal Developer Platform (IDP) is the whole stack a platform team runs so product teams can self-serve — clusters, pipelines, secret management, policy, observability. Backstage is the portal: the UI/UX layer over that platform, where golden paths are discovered and triggered. It is the front door, not the building. The classic error (its own entry in Common beginner mistakes) is conflating the two. Backstage does not deploy your app — Argo CD or Flux does. It does not hold your secrets — Vault does. It does not enforce policy — Kyverno or Gatekeeper does. What Backstage adds is discoverability and self-service: it makes the platform’s capabilities legible and one click away. Spotify frames the goal as golden paths; Team Topologies frames it as reducing teams’ cognitive load. The portal is where a paved path is advertised and walked.

Scaling and hosting

The backend is largely stateless, which makes PostgreSQL the state — it holds the catalog graph, TechDocs metadata, scaffolder task history, and sessions. Scale the frontend/backend horizontally, but know that the catalog processing loop and the task scheduler coordinate through the database, so extra replicas share the work rather than duplicating it; size Postgres, not just the pods. For a large org, prefer a GitHub App over a PAT for the higher rate limit, keep the Kubernetes refresh interval sane (Cost notes), and consider sharding discovery if a single org scan gets slow. Hosting is a build-vs-buy call: self-host on Kubernetes (this lesson), or adopt a managed distribution — Red Hat Developer Hub, Spotify Portal, or Roadie — if you would rather not own the upgrade treadmill, which moves fast and occasionally breaks plugins.

Practice challenges

Work these in order; each builds on the last. Solutions are collapsed — try first, then check.

1. (Beginner) Register a service by hand. Write a minimal, valid catalog-info.yaml for a service notifications-api, owned by the comms-team group, that will also work with the Kubernetes and TechDocs plugins.

<details><summary>Solution</summary>

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: notifications-api
  description: Transactional notification delivery
  annotations:
    github.com/project-slug: kloudvin/notifications-api
    backstage.io/kubernetes-label-selector: 'app=notifications-api'
    backstage.io/techdocs-ref: dir:.
spec:
  type: service
  lifecycle: production
  owner: group:default/comms-team
  system: messaging

The two annotations are what light up the Kubernetes tab (label selector) and the Docs tab (techdocs-ref); without an owner that resolves to a real Group, ownership queries return nothing. </details>

2. (Beginner→Intermediate) Model a relationship. Extend payments-ledger so the catalog shows it provides an API called payments-api. Give both entities.

<details><summary>Solution</summary>

Add to the component’s spec:

spec:
  providesApis:
    - payments-api

And declare the API entity (its own catalog-info.yaml, or the same file as a second document):

apiVersion: backstage.io/v1alpha1
kind: API
metadata:
  name: payments-api
spec:
  type: openapi
  lifecycle: production
  owner: group:default/payments-team
  system: settlement
  definition:
    $text: ./openapi.yaml

The processing loop derives a providesApi/apiProvidedBy relation, so the API appears on the component page and vice-versa. $text pulls the spec inline from the repo. </details>

3. (Intermediate) Debug an empty Kubernetes tab. The tab renders nothing for payments-ledger. List, in order, the things to check — and name the one that is usually the culprit.

<details><summary>Solution</summary>

  1. Label selector vs workload labels — does backstage.io/kubernetes-label-selector: 'app=payments-ledger' match the labels the Deployment/Pods actually carry? This is the usual culprit.
  2. Cluster reachability / RBAC — can the reader token list pods on that cluster? (kubectl auth can-i list pods -A with the token.)
  3. Namespace — if the workload is in a non-default namespace and you scoped by one, add or correct backstage.io/kubernetes-namespace.

Nine times out of ten it is #1 — the selector and the real labels disagree. </details>

4. (Intermediate) Move TechDocs off the local builder. An app-config has techdocs.builder: 'local'. Give the config change and the two CI commands that build and publish docs to the S3 bucket kloudvin-techdocs instead.

<details><summary>Solution</summary>

techdocs:
  builder: 'external'
  generator:
    runIn: 'local'
  publisher:
    type: 'awsS3'
    awsS3:
      bucketName: 'kloudvin-techdocs'
      region: 'eu-west-1'

In CI, per service repo:

npx @techdocs/cli generate --no-docker
npx @techdocs/cli publish --publisher-type awsS3 \
  --storage-name kloudvin-techdocs --entity default/component/payments-ledger

Now the portal only serves the pre-built bundle; it never runs MkDocs at request time. </details>

5. (Advanced) Write a self-service template. Sketch a scaffolder Template that takes a service name and an owner (picked from real Groups), creates a GitHub repo from a skeleton, and registers it in the catalog.

<details><summary>Solution</summary>

The golden-path-service template in “Going deeper” is the reference answer: a parameters block with an OwnerPicker filtered to kind: Group, then fetch:templatepublish:githubcatalog:register steps, and an output.links block so the user lands on the new repo and catalog entry. The one non-negotiable is that owner is required and comes from a real Group — that is the guardrail that keeps the golden path compliant. </details>

6. (Advanced) Restrict a permission. By default any signed-in user can delete a catalog entity. Write the permission-policy logic that allows delete only for an entity’s owner.

<details><summary>Solution</summary>

In your PermissionPolicy.handle(), match the delete permission and return a conditional decision keyed to ownership:

if (isPermission(request.permission, catalogEntityDeletePermission)) {
  return createCatalogConditionalDecision(request.permission, {
    rule: 'IS_ENTITY_OWNER',
    resourceType: 'catalog-entity',
    params: { claims: user?.identity.ownershipEntityRefs ?? [] },
  });
}
return { result: AuthorizeResult.ALLOW };

The framework evaluates IS_ENTITY_OWNER against each entity at request time, so a user can delete only what their Groups own. Everything else stays ALLOW. </details>

Common beginner mistakes

These are misconceptions, not error messages — the wrong mental model behind a class of problems (distinct from the symptom-level Common pitfalls above).

Glossary

BackstageKubernetesTechDocsDeveloper PortalPlatform EngineeringIDP
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments