Platform Engineering: Backstage Deep Dive — Software Catalog, Scaffolder Templates, TechDocs, and Plugins
Build a production Internal Developer Portal with Backstage — software catalog with YAML descriptors, Scaffolder golden path templates that provision real services, TechDocs as code, custom plugins for internal tooling, and catalog accuracy strategies.
What Backstage Solves
Large engineering organizations have a discovery problem: hundreds of services, no canonical list of what exists, who owns it, where the docs are, how to deploy it, or what it depends on. Engineers spend time asking Slack instead of building.
Backstage is Spotify's open-source Internal Developer Portal — a single place to:
- Discover all services, APIs, libraries, and infrastructure
- Create new services via golden path templates (scaffolder)
- Document everything in Markdown (TechDocs)
- Track service health, ownership, and compliance
- Extend with plugins for any internal tool (Argo CD, PagerDuty, Kubernetes, cost)
Getting Started: Deploy Backstage
# Create a new Backstage app
npx @backstage/create-app@latest
cd my-backstage-app
# Run locally
yarn devFor Kubernetes deployment:
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY . .
RUN yarn install --frozen-lockfile
RUN yarn tsc
RUN yarn build:backend
FROM node:20-bookworm-slim
WORKDIR /app
COPY --from=build /app/packages/backend/dist/ ./
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "index.js"]# backstage-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backstage
namespace: backstage
spec:
replicas: 2
template:
spec:
containers:
- name: backstage
image: registry.example.com/backstage:latest
env:
- name: APP_CONFIG_app_baseUrl
value: "https://portal.example.com"
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: backstage-secrets
key: github-token
- name: POSTGRES_HOST
value: backstage-db.backstage.svc
ports:
- containerPort: 7007Software Catalog: catalog-info.yaml
Every service, API, library, and team is described by a catalog-info.yaml file at the root of the repository. Backstage discovers these via GitHub/GitLab integrations.
Service (Component)
# catalog-info.yaml — in the root of the payment-service repo
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-service
title: Payment Service
description: Handles payment processing, refunds, and billing
annotations:
# Backstage plugin integrations
github.com/project-slug: org/payment-service
backstage.io/techdocs-ref: dir:. # TechDocs in this repo
argo-cd/app-name: payment-service-production
pagerduty.com/service-id: PABC123
backstage.io/kubernetes-id: payment-service
# Cost tracking
finops.example.com/cost-center: "payments-team"
links:
- url: https://grafana.example.com/d/payment-service
title: Grafana Dashboard
icon: dashboard
- url: https://runbooks.example.com/payment-service
title: Runbook
icon: docs
tags:
- payment
- critical
- typescript
spec:
type: service
lifecycle: production
owner: group:payments-team
system: checkout-system
dependsOn:
- component:inventory-service
- component:notification-service
- resource:orders-db
providesApis:
- payment-apiAPI descriptor
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: payment-api
description: REST API for payment processing
spec:
type: openapi
lifecycle: production
owner: group:payments-team
definition:
$text: ./openapi.yaml # local OpenAPI specBackstage renders the OpenAPI spec as interactive documentation in the catalog.
System: group related components
# A System groups components that deliver a user-facing capability
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: checkout-system
description: End-to-end checkout flow (cart, payment, inventory, notifications)
spec:
owner: group:checkout-team
domain: domain:ecommerceAutodiscovery from GitHub
# app-config.yaml — discover catalog-info.yaml from all repos
catalog:
providers:
github:
org:
organization: my-org
catalogPath: /catalog-info.yaml
filters:
branch: main
repository: .* # all repos
schedule:
frequency: { minutes: 30 }
timeout: { minutes: 3 }Backstage scans every GitHub repo every 30 minutes. Any repo with catalog-info.yaml is registered automatically. No manual registration needed.
Scaffolder: Golden Path Templates
The Scaffolder creates new services from templates — a Git repo, Kubernetes manifests, ArgoCD application, and catalog registration all in one click.
Template anatomy
# template.yaml — the golden path for a new TypeScript service
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: typescript-service
title: TypeScript Microservice
description: Create a new TypeScript service with Docker, Kubernetes manifests, and ArgoCD
tags:
- typescript
- recommended
spec:
owner: group:platform-team
type: service
# Step 1: Input form — shown to the developer
parameters:
- title: Service Details
required: [name, owner, description]
properties:
name:
title: Service Name
type: string
pattern: '^[a-z][a-z0-9-]*$'
description: "Lowercase, hyphens only (e.g., order-processor)"
ui:autofocus: true
description:
title: Description
type: string
owner:
title: Team
type: string
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
system:
title: System
type: string
ui:field: EntityPicker
ui:options:
catalogFilter:
kind: System
- title: Infrastructure
properties:
needsDatabase:
title: Provision a PostgreSQL database?
type: boolean
default: false
databaseSize:
title: Database size
type: string
enum: [small, medium, large]
default: small
ui:widget: select
- title: Repository
required: [repoUrl]
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
allowedOrganizations:
- my-org
# Step 2: Actions — what Backstage does
steps:
# 1. Fetch the skeleton (template files)
- id: fetch-template
name: Fetch template files
action: fetch:template
input:
url: ./skeleton # folder with template files
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
system: ${{ parameters.system }}
needsDatabase: ${{ parameters.needsDatabase }}
databaseSize: ${{ parameters.databaseSize }}
# 2. Create the GitHub repository
- id: create-repo
name: Create GitHub repository
action: publish:github
input:
allowedHosts: ['github.com']
repoUrl: ${{ parameters.repoUrl }}
description: ${{ parameters.description }}
defaultBranch: main
gitCommitMessage: "chore: initial scaffold from platform template"
topics:
- ${{ parameters.system }}
- typescript
# 3. Create ArgoCD Application
- id: create-argocd-app
name: Register in ArgoCD
action: argocd:create-application
input:
appName: ${{ parameters.name }}
argoInstance: production
namespace: ${{ parameters.name }}
repoUrl: ${{ steps['create-repo'].output.remoteUrl }}
path: k8s/overlays/production
# 4. Register in Backstage catalog
- id: register-catalog
name: Register in catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps['create-repo'].output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
# 5. (Optional) Provision database via Crossplane
- id: provision-database
name: Provision PostgreSQL database
if: ${{ parameters.needsDatabase }}
action: kubernetes:apply
input:
namespace: ${{ parameters.name }}
manifest: |
apiVersion: platform.example.com/v1alpha1
kind: PostgresDatabase
metadata:
name: ${{ parameters.name }}-db
namespace: ${{ parameters.name }}
spec:
parameters:
size: ${{ parameters.databaseSize }}
environment: production
# Step 3: Output links shown after creation
output:
links:
- title: Repository
url: ${{ steps['create-repo'].output.remoteUrl }}
- title: ArgoCD Application
url: https://argocd.example.com/applications/${{ parameters.name }}
- title: Catalog Entry
icon: catalog
entityRef: ${{ steps['register-catalog'].output.entityRef }}Template skeleton files
The ./skeleton folder contains templated files with ${{ values.* }} substitutions:
skeleton/
├── catalog-info.yaml # auto-registered
├── README.md
├── src/
│ └── index.ts
├── Dockerfile
├── package.json
└── k8s/
├── base/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
└── overlays/
├── staging/
└── production/# skeleton/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
description: ${{ values.description }}
annotations:
github.com/project-slug: my-org/${{ values.name }}
backstage.io/techdocs-ref: dir:.
spec:
type: service
lifecycle: production
owner: ${{ values.owner }}
system: ${{ values.system }}A developer fills the form → Backstage creates the repo, creates the namespace, registers it in ArgoCD, provisions the database (if requested), and registers the catalog entry — in under 2 minutes.
TechDocs: Documentation as Code
TechDocs renders Markdown (MkDocs) from the service repo directly in Backstage. No separate wiki required.
Setup
# In the service repo: mkdocs.yml
site_name: Payment Service
site_description: Handles payment processing, refunds, and billing
docs_dir: docs/
nav:
- Overview: index.md
- Architecture: architecture.md
- API Reference: api.md
- Runbooks:
- Deploy: runbooks/deploy.md
- Incident Response: runbooks/incidents.md
- ADRs:
- "ADR-001: Use Stripe": adrs/001-stripe.md
- "ADR-002: Idempotency keys": adrs/002-idempotency.md
plugins:
- techdocs-core# catalog-info.yaml annotation
annotations:
backstage.io/techdocs-ref: dir:. # docs/ folder in this repoBackstage fetches, builds, and serves the MkDocs site. Docs live in the same repo as the code — they get reviewed in PRs, stay in sync with the code, and are searchable from the catalog.
Kubernetes Plugin: Real-Time Service Status
The @backstage/plugin-kubernetes plugin shows live Kubernetes status in the catalog:
# app-config.yaml
kubernetes:
serviceLocatorMethod:
type: multiTenant
clusterLocatorMethods:
- type: config
clusters:
- url: https://prod-eu.example.com
name: production-eu
authProvider: serviceAccount
serviceAccountToken: ${K8S_SA_TOKEN_PROD_EU}
caData: ${K8S_CA_PROD_EU}
- url: https://prod-us.example.com
name: production-us
authProvider: serviceAccount
serviceAccountToken: ${K8S_SA_TOKEN_PROD_US}Backstage matches components to Kubernetes resources via the backstage.io/kubernetes-id annotation. On the component page: live pod count, restart count, CPU/memory usage, recent events — across all clusters simultaneously.
ArgoCD Plugin: Deployment Status in Catalog
yarn workspace app add @roadiehq/backstage-plugin-argo-cd
yarn workspace backend add @roadiehq/backstage-plugin-argo-cd-backend# app-config.yaml
argocd:
baseUrl: https://argocd.example.com
token: ${ARGOCD_TOKEN}
appLocatorMethods:
- type: config
instances:
- name: production
url: https://argocd.example.com
token: ${ARGOCD_TOKEN}Each component's Backstage page shows ArgoCD sync status, last deploy time, current image tag, and sync history — no need to open ArgoCD for a quick status check.
Catalog Accuracy: Keeping It Fresh
The #1 failure mode for Backstage: catalog entries that go stale. Services get decommissioned but their catalog-info.yaml stays in Git. Owners change but annotations aren't updated.
Automated catalog validation with Kyverno
# Enforce catalog-info.yaml exists in every repo (via CI check)
# .github/workflows/catalog-check.yml
name: Catalog Validation
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate catalog-info.yaml
run: |
if [ ! -f catalog-info.yaml ]; then
echo "ERROR: catalog-info.yaml missing"
exit 1
fi
# Validate schema
npx @backstage/catalog-validator catalog-info.yamlLifecycle labels for decommissioning
spec:
lifecycle: deprecated # shows warning banner in Backstage
# or
lifecycle: experimental # not yet production
# or
lifecycle: production # activeUse deprecated before deleting a service. Backstage shows a banner and tools can query "all deprecated services" for cleanup tracking.
Automated ownership check
Run a weekly job that:
- Queries all Backstage components:
GET /api/catalog/entities?filter=kind=Component - Checks each owner group exists in the catalog
- Checks each repo still exists in GitHub
- Posts a Slack report of stale/orphaned catalog entries to the platform team
Platform Home Page
Backstage's @backstage/plugin-home plugin creates a customizable home page:
// packages/app/src/components/home/HomePage.tsx
export const HomePage = () => (
<Page themeId="home">
<Content>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<WelcomeTitle />
<SearchBar />
</Grid>
<Grid item xs={12} md={6}>
<HomePageStarredEntities />
</Grid>
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited />
</Grid>
<Grid item xs={12} md={4}>
{/* Quick links to scaffolder templates */}
<InfoCard title="Create New">
<CreateComponentButton />
</InfoCard>
</Grid>
<Grid item xs={12} md={8}>
{/* Company announcements */}
<AnnouncementsCard />
</Grid>
</Grid>
</Content>
</Page>
);Measuring Backstage ROI
Track these metrics to demonstrate value to leadership:
Adoption:
- Active users per week (Backstage analytics)
- % of services with catalog entries
- % of services with TechDocs
Developer productivity:
- Time to create a new service (Scaffolder: before → after)
- P50/P95 time from "idea" to "first deploy" (tracked via ArgoCD)
- "Time to find the owner of X" (survey — target: < 2 minutes)
Platform quality:
- % of services with Grafana dashboard
- % of services with runbooks
- % of services with owner group
- % of deprecated services cleaned up within 30 days
Golden path adoption:
- Services created via Scaffolder vs created manually
- Scaffold template usage breakdown (which templates are most used)Present these in quarterly platform reviews. The goal: Backstage should reduce the time engineers spend on meta-work (finding things, asking Slack, reading stale wikis) by 50%+.
Enjoyed this article?
Explore the Cloud & DevOps learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.