Developer Portal
1. Backstage Overview
Backstage (CNCF Graduated) is an open platform for building Internal Developer Portals. It provides a unified interface where developers can discover services, create new projects from templates, read documentation, view Kubernetes workload status, and track technical health — all without switching between a dozen different tools.
Backstage components:
┌──────────────────────────────────────────────────────────┐
│ Backstage Portal │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Service Catalog │ │
│ │ All services, APIs, libraries, pipelines, teams │ │
│ │ Ingested from catalog-info.yaml files in Git repos│ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ Software │ │ TechDocs │ │
│ │ Templates │ │ (docs-as-code, MkDocs → │ │
│ │ (Scaffolder) │ │ rendered in portal) │ │
│ └──────────────────┘ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────── ┐ │
│ │ Plugins │ │
│ │ Kubernetes • Argo CD • GitHub Actions • Grafana │ │
│ │ PagerDuty • SonarQube • Snyk • Cost • Lighthouse │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Scorecards / Tech Health │ │
│ │ Track adoption: SLOs, docs, on-call, security │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Backstage ships as an open-source framework that you customise, build, and deploy. Unlike SaaS portals, you own the instance, the data, and the plugin integrations. The platform team maintains it as a product with a roadmap, versioning, and adoption metrics. This ownership model is why ~50% of enterprises that adopt it succeed, and ~50% abandon it after 6 months due to underestimating the investment required.
2. Deploying Backstage on Kubernetes
Architecture
┌──────────────────────────────────────────────────────┐
│ Backstage Deployment (Node.js app) │
│ ├── backend (Express.js + plugins) │
│ └── frontend (React SPA served by backend) │
│ │
│ External deps: │
│ ├── PostgreSQL (catalog, auth, scaffolder state) │
│ ├── GitHub / GitLab (catalog ingestion, auth, SCM) │
│ ├── Okta / Google OIDC (authentication) │
│ └── Object storage (TechDocs HTML artifacts) │
└──────────────────────────────────────────────────────┘
Helm Deployment
# Build and push Backstage Docker image (required — Backstage isn't shipped as a prebuilt image)
# In your Backstage app directory:
yarn install
yarn tsc
yarn build:backend
docker build -t 123456789.dkr.ecr.us-east-1.amazonaws.com/backstage:${GIT_SHA} \
--file packages/backend/Dockerfile .
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/backstage:${GIT_SHA}
# Deploy via Helm
helm repo add backstage https://backstage.github.io/charts
helm repo update
helm upgrade --install backstage backstage/backstage \
--namespace backstage \
--create-namespace \
--values backstage-values.yaml
# backstage-values.yaml
backstage:
image:
registry: 123456789.dkr.ecr.us-east-1.amazonaws.com
repository: backstage
tag: "abc1234"
pullPolicy: IfNotPresent
replicas: 2
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
memory: 1Gi
# app-config.yaml content injected as environment variables
appConfig:
app:
title: "MyOrg Developer Portal"
baseUrl: https://portal.internal.example.com
organization:
name: "MyOrg"
backend:
baseUrl: https://portal.internal.example.com
listen:
port: 7007
database:
client: pg
connection:
host: ${POSTGRES_HOST}
port: 5432
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
database: backstage
integrations:
github:
- host: github.com
token: ${GITHUB_TOKEN} # Personal access token or GitHub App
auth:
providers:
github:
development:
clientId: ${GITHUB_CLIENT_ID}
clientSecret: ${GITHUB_CLIENT_SECRET}
okta:
production:
clientId: ${OKTA_CLIENT_ID}
clientSecret: ${OKTA_CLIENT_SECRET}
audience: https://myorg.okta.com
authServerId: default
catalog:
providers:
github:
myorg:
organization: myorg
catalogPath: '/catalog-info.yaml'
filters:
branch: main
repository: '.*' # all repos
schedule:
frequency: {minutes: 30}
timeout: {minutes: 3}
techdocs:
builder: external # generate docs in CI, serve from S3
generator:
runIn: local
publisher:
type: awsS3
awsS3:
bucketName: myorg-techdocs
region: us-east-1
extraEnvVarsSecret: backstage-secrets
postgresql:
enabled: true
primary:
persistence:
size: 20Gi
resources:
requests:
memory: 256Mi
cpu: 250m
ingress:
enabled: true
ingressClassName: nginx
host: portal.internal.example.com
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
tls:
enabled: true
# backstage-secrets (Kubernetes Secret managed by ESO or SOPS):
apiVersion: v1
kind: Secret
metadata:
name: backstage-secrets
namespace: backstage
stringData:
POSTGRES_HOST: postgres.backstage.svc
POSTGRES_USER: backstage
POSTGRES_PASSWORD: ""
GITHUB_TOKEN: ""
GITHUB_CLIENT_ID: ""
GITHUB_CLIENT_SECRET: ""
OKTA_CLIENT_ID: ""
OKTA_CLIENT_SECRET: ""
3. Service Catalog
The service catalog is the core of Backstage — a searchable, browsable inventory of every component, API, resource, system, domain, and team in the organisation. Entities are defined by catalog-info.yaml files in each Git repository and automatically ingested by Backstage.
Entity Kinds
| Kind | Description | Example |
|---|---|---|
Component | A software component (service, library, website, tool) | order-service, payment-sdk |
API | An API exposed by a Component (OpenAPI, AsyncAPI, GraphQL, gRPC) | Order API v2 (OpenAPI spec) |
System | A collection of related Components and APIs | order-management-system |
Domain | A business domain grouping related Systems | ecommerce, payments |
Group | A team or organisational unit | platform-team, order-squad |
User | An individual developer (synced from identity provider) | alice@example.com |
Resource | Infrastructure resources (databases, S3 buckets, queues) | orders-rds-postgres, payments-sqs |
Location | A pointer to other catalog-info.yaml files | all-services.yaml in org root |
4. catalog-info.yaml Reference
Component — Service
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: order-service
title: Order Service
description: |
Core service for creating and managing customer orders.
Handles order lifecycle from cart submission to fulfillment.
annotations:
# Source location (used by Backstage to link to code)
backstage.io/source-location: url:https://github.com/myorg/order-service
# TechDocs (mkdocs.yml location)
backstage.io/techdocs-ref: dir:.
# GitHub Actions integration (shows CI status)
github.com/project-slug: myorg/order-service
# Kubernetes integration (shows pod/deployment status)
backstage.io/kubernetes-id: order-service
backstage.io/kubernetes-namespace: order-service
# Argo CD integration
argocd/app-name: order-service
# PagerDuty integration (shows on-call + incidents)
pagerduty.com/service-id: PXXXXX
# Prometheus/Grafana link
grafana/dashboard-selector: "title='Order Service RED'"
grafana/alert-label-selector: "service=order-service"
tags:
- go
- grpc
- critical-path
links:
- url: https://grafana.internal.example.com/d/order-service
title: Grafana Dashboard
icon: dashboard
- url: https://runbooks.internal.example.com/order-service
title: Runbook
icon: docs
spec:
type: service # service | library | website | tool
lifecycle: production # experimental | production | deprecated
owner: group:order-squad
system: order-management-system
# APIs this component provides
providesApis:
- order-api-v2
# APIs this component consumes
consumesApis:
- payment-api-v1
- inventory-api-v1
# Other components this component depends on
dependsOn:
- component:payment-service
- resource:orders-rds-postgres
API Entity
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: order-api-v2
description: REST API for order management
annotations:
backstage.io/source-location: url:https://github.com/myorg/order-service/blob/main/api/openapi.yaml
spec:
type: openapi # openapi | asyncapi | graphql | grpc
lifecycle: production
owner: group:order-squad
system: order-management-system
definition:
$text: https://raw.githubusercontent.com/myorg/order-service/main/api/openapi.yaml
System and Domain
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: order-management-system
description: All services involved in order lifecycle management
spec:
owner: group:order-squad
domain: ecommerce
---
apiVersion: backstage.io/v1alpha1
kind: Domain
metadata:
name: ecommerce
description: Customer-facing e-commerce capabilities
spec:
owner: group:ecommerce-platform
Resource Entity — Cloud Resource
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: orders-rds-postgres
description: "PostgreSQL 15 RDS instance for order data"
annotations:
aws.amazon.com/arn: arn:aws:rds:us-east-1:123456789:db:orders-prod
spec:
type: database # database | s3-bucket | message-queue | cache | etc.
owner: group:platform-team
system: order-management-system
dependencyOf:
- component:order-service
Group and User
# Groups and Users are typically synced from the identity provider
# (Okta, GitHub, Azure AD) rather than written manually.
# But you can define them in YAML for bootstrap:
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: order-squad
description: Team responsible for order management
spec:
type: team
profile:
displayName: Order Squad
email: order-squad@example.com
picture: https://avatars.internal/order-squad.png
parent: ecommerce-tribe # org hierarchy
children: []
members:
- alice
- bob
- carol
5. Software Templates
Software Templates (the Scaffolder) let developers create new services, libraries, and infrastructure from standardised golden-path templates — directly from the Backstage UI, without needing to copy boilerplate or understand Kubernetes YAML.
Template Structure
my-go-service-template/
├── template.yaml ← Template definition (Backstage reads this)
├── skeleton/ ← File tree to copy (Nunjucks templated)
│ ├── cmd/
│ │ └── server/
│ │ └── main.go
│ ├── Dockerfile
│ ├── helm/
│ │ ├── Chart.yaml
│ │ └── values.yaml
│ ├── .github/
│ │ └── workflows/
│ │ └── ci.yaml
│ ├── catalog-info.yaml
│ └── docs/
│ ├── mkdocs.yml
│ └── index.md
└── README.md
Full Template YAML
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: go-microservice-template
title: Go Microservice
description: Creates a production-ready Go microservice with CI/CD, Helm chart, and observability pre-wired
tags:
- go
- microservice
- golden-path
spec:
owner: group:platform-team
type: service
# ── Input Parameters (shown as a form in the UI) ──────────────
parameters:
- title: Service Details
required: [name, description, owner]
properties:
name:
title: Service Name
type: string
description: "Lowercase, hyphenated (e.g. order-service)"
pattern: '^[a-z][a-z0-9-]*$'
maxLength: 50
ui:autofocus: true
description:
title: Description
type: string
description: What does this service do?
owner:
title: Owning Team
type: string
description: Team responsible for this service
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
system:
title: System
type: string
description: Which system does this belong to?
ui:field: EntityPicker
ui:options:
catalogFilter:
kind: System
- title: Repository
required: [repoUrl]
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts: [github.com]
allowedOrganizations: [myorg]
- title: Infrastructure
properties:
enableDatabase:
title: Needs PostgreSQL database?
type: boolean
default: false
enableCache:
title: Needs Redis cache?
type: boolean
default: false
enableQueue:
title: Needs SQS queue?
type: boolean
default: false
# ── Steps (actions executed after form submission) ────────────
steps:
- id: fetch-template
name: Fetch Template
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
system: ${{ parameters.system | parseEntityRef | pick('name') }}
enableDatabase: ${{ parameters.enableDatabase }}
enableCache: ${{ parameters.enableCache }}
repoName: ${{ parameters.repoUrl | parseRepoUrl | pick('repo') }}
orgName: ${{ parameters.repoUrl | parseRepoUrl | pick('owner') }}
- id: publish
name: Create GitHub Repository
action: publish:github
input:
allowedHosts: [github.com]
description: ${{ parameters.description }}
repoUrl: ${{ parameters.repoUrl }}
defaultBranch: main
repoVisibility: private
protectDefaultBranch: true
requireCodeOwnerReviews: true
topics:
- go
- microservice
- ${{ parameters.owner | parseEntityRef | pick('name') }}
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
- id: create-argocd-app
name: Create Argo CD Application
action: argocd:create-resources
input:
appName: ${{ parameters.name }}
argoInstance: production
namespace: ${{ parameters.name }}
projectName: ${{ parameters.owner | parseEntityRef | pick('name') }}
repoUrl: https://github.com/${{ parameters.repoUrl | parseRepoUrl | pick('owner') }}/${{ parameters.repoUrl | parseRepoUrl | pick('repo') }}
path: helm
- id: create-namespace
name: Create Kubernetes Namespace
action: kubernetes:apply
input:
clusterUrl: https://k8s.internal.example.com
manifest:
apiVersion: v1
kind: Namespace
metadata:
name: ${{ parameters.name }}
labels:
team: ${{ parameters.owner | parseEntityRef | pick('name') }}
- id: notify-slack
name: Notify Team Slack Channel
action: http:backstage:request
input:
method: POST
path: /api/proxy/slack
body:
text: "🚀 New service *${{ parameters.name }}* created by ${{ user.entity.metadata.name }}"
# ── Output (links shown to developer after template runs) ─────
output:
links:
- title: Open Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Open in Catalog
entityRef: ${{ steps.register.output.entityRef }}
- title: CI Pipeline
url: https://github.com/${{ parameters.repoUrl | parseRepoUrl | pick('owner') }}/${{ parameters.repoUrl | parseRepoUrl | pick('repo') }}/actions
Skeleton File Templating (Nunjucks)
# skeleton/catalog-info.yaml (template with Nunjucks)
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
description: ${{ values.description }}
annotations:
backstage.io/techdocs-ref: dir:.
github.com/project-slug: ${{ values.orgName }}/${{ values.repoName }}
backstage.io/kubernetes-id: ${{ values.name }}
spec:
type: service
lifecycle: experimental
owner: ${{ values.owner }}
system: ${{ values.system }}
# skeleton/helm/values.yaml
replicaCount: 2
image:
repository: 123456789.dkr.ecr.us-east-1.amazonaws.com/${{ values.name }}
pullPolicy: IfNotPresent
tag: "" # overridden by CI
service:
type: ClusterIP
port: 80
targetPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 80
livenessProbe:
httpGet:
path: /health/live
port: 8080
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
6. Template Actions & Custom Actions
Built-in Actions Reference
| Action | Description |
|---|---|
fetch:template | Copy and render skeleton directory with Nunjucks values |
fetch:plain | Copy plain files without templating |
publish:github | Create GitHub repo, push files, configure branch protection |
publish:gitlab | Create GitLab project and push files |
catalog:register | Register the new entity in the Backstage catalog |
catalog:write | Write a catalog entity to disk before publishing |
github:actions:dispatch | Trigger a GitHub Actions workflow |
argocd:create-resources | Create Argo CD Application and AppProject |
kubernetes:apply | Apply manifest to a registered Kubernetes cluster |
http:backstage:request | Make HTTP request to a backend proxy endpoint |
debug:log | Log a message during scaffolding (dry-run debugging) |
Custom Action (Node.js Backend)
// packages/backend/src/actions/createVaultNamespace.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import Vault from 'node-vault';
export function createVaultNamespaceAction() {
return createTemplateAction<{
namespaceName: string;
teamName: string;
}>({
id: 'vault:create-namespace',
schema: {
input: {
required: ['namespaceName', 'teamName'],
type: 'object',
properties: {
namespaceName: { type: 'string', title: 'Vault namespace name' },
teamName: { type: 'string', title: 'Team name for policy' },
},
},
},
async handler(ctx) {
const { namespaceName, teamName } = ctx.input;
const { logger } = ctx;
const vault = Vault({
endpoint: process.env.VAULT_ADDR,
token: process.env.VAULT_TOKEN,
});
logger.info(`Creating Vault namespace: ${namespaceName}`);
// Create KV v2 secrets mount for the namespace
await vault.mount({
mount_point: `${namespaceName}/`,
type: 'kv-v2',
description: `Secrets for ${teamName}`,
});
// Create policy
await vault.addPolicy({
name: `${namespaceName}-rw`,
rules: `
path "${namespaceName}/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
`,
});
logger.info(`Vault namespace and policy created for ${namespaceName}`);
ctx.output('vaultPath', `${namespaceName}/`);
ctx.output('vaultPolicy', `${namespaceName}-rw`);
},
});
}
7. TechDocs
TechDocs renders Markdown documentation (using MkDocs) directly in the Backstage portal, linked to each service's catalog entry. The "docs-like-code" approach keeps documentation alongside source code, versioned in Git, and always up to date.
Repository Setup
# mkdocs.yml (in service root)
site_name: Order Service
site_description: Documentation for the Order Service
repo_url: https://github.com/myorg/order-service
plugins:
- techdocs-core # required Backstage plugin
nav:
- Home: index.md
- Architecture:
- Overview: architecture/overview.md
- Data Model: architecture/data-model.md
- API Design: architecture/api-design.md
- Operations:
- Deployment: ops/deployment.md
- Runbook: ops/runbook.md
- Alerting: ops/alerting.md
- Development:
- Getting Started: dev/getting-started.md
- Testing: dev/testing.md
- Configuration: dev/configuration.md
TechDocs Build in CI (External Builder)
# .github/workflows/techdocs.yaml
name: TechDocs
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'mkdocs.yml'
- 'catalog-info.yaml'
jobs:
publish-techdocs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install techdocs-cli
run: npm install -g @techdocs/cli
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/techdocs-s3-writer
aws-region: us-east-1
- name: Generate and publish TechDocs
run: |
techdocs-cli generate \
--source-dir . \
--output-dir ./site \
--no-docker
techdocs-cli publish \
--publisher-type awsS3 \
--storage-name myorg-techdocs \
--entity default/Component/order-service
App Config for External TechDocs
techdocs:
builder: external # CI generates, Backstage just serves
generator:
runIn: local
publisher:
type: awsS3
awsS3:
bucketName: myorg-techdocs
region: us-east-1
credentials:
roleArn: arn:aws:iam::123456789:role/backstage-techdocs-reader
8. Essential Plugins
Kubernetes Plugin
# app-config.yaml — Kubernetes plugin config
kubernetes:
serviceLocatorMethod:
type: multiTenant # each service entity gets pods across all clusters
clusterLocatorMethods:
- type: config
clusters:
- url: https://prod-us.k8s.internal
name: production-us
authProvider: serviceAccount
serviceAccountToken: ${K8S_PROD_US_TOKEN}
caData: ${K8S_PROD_US_CA}
- url: https://staging.k8s.internal
name: staging
authProvider: serviceAccount
serviceAccountToken: ${K8S_STAGING_TOKEN}
caData: ${K8S_STAGING_CA}
customResources:
- group: argoproj.io
apiVersion: v1alpha1
plural: rollouts
# catalog-info.yaml — link entity to Kubernetes workloads
annotations:
backstage.io/kubernetes-id: order-service # matches Deployment label
backstage.io/kubernetes-namespace: order-service # which namespace to look in
backstage.io/kubernetes-label-selector: 'app=order-service'
Argo CD Plugin
# app-config.yaml
argocd:
username: ${ARGOCD_USERNAME}
password: ${ARGOCD_PASSWORD}
appLocatorMethods:
- type: config
instances:
- name: production
url: https://argocd.internal.example.com
token: ${ARGOCD_TOKEN}
# catalog-info.yaml annotation
argocd/app-name: order-service
# or for multiple instances:
# argocd/app-selector: "app.kubernetes.io/name=order-service"
GitHub Actions Plugin
# app-config.yaml
github:
- host: github.com
token: ${GITHUB_TOKEN}
# catalog-info.yaml annotation
github.com/project-slug: myorg/order-service
Plugin Ecosystem Overview
| Plugin | Provider | What It Shows |
|---|---|---|
| @backstage/plugin-kubernetes | Core | Pod status, deployments, rollouts per service |
| @roadiehq/backstage-plugin-argo-cd | Roadie | Argo CD app sync status, history |
| @backstage/plugin-github-actions | Core | CI workflow runs and status |
| @backstage/plugin-pagerduty | Core | On-call schedule, active incidents |
| @roadiehq/backstage-plugin-grafana | Roadie | Embedded Grafana dashboards and alerts |
| @backstage/plugin-cost-insights | Core | Cloud cost per team/service |
| @backstage/plugin-sonarqube | Core | Code quality metrics and issues |
| @backstage/plugin-vault | Community | Vault secret paths per service |
| @backstage/plugin-todo | Core | TODO/FIXME comments in source code |
| @backstage/plugin-tech-radar | Core | Technology adoption radar (ADOPT/TRIAL/HOLD) |
| @backstage/plugin-search | Core | Full-text search across catalog, TechDocs, APIs |
| @backstage/plugin-lighthouse | Core | Lighthouse website performance audits |
9. Scorecards & Tech Health
Scorecards track whether services meet the organisation's engineering standards. They answer questions like: "Does every production service have an SLO defined?", "Does every service have an on-call rotation?", "Are all services scanning for vulnerabilities in CI?"
Backstage Entity Metadata for Scorecards
# catalog-info.yaml — add metadata that scorecards can check
metadata:
annotations:
# On-call (PagerDuty service ID)
pagerduty.com/service-id: PXXXXX
# SLO defined (custom annotation)
slo.io/slo-defined: "true"
# Last security scan
security.io/last-scan: "2024-01-15"
# Runbook URL
runbook.io/url: https://runbooks.internal/order-service
labels:
# Compliance tier
compliance-tier: "high"
# Data classification
data-classification: "pii"
Custom Scorecard Plugin (using Backstage Catalog API)
# Scorecards are typically implemented as a custom Backstage plugin
# or via a commercial tool like Cortex, OpsLevel, or Rely.io.
# Below is a representative pattern for a custom implementation:
// packages/app/src/components/Scorecard/ScorecardCard.tsx
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
interface Check {
name: string;
passed: boolean;
detail?: string;
}
function ScorecardCard() {
const { entity } = useEntity();
const annotations = entity.metadata.annotations ?? {};
const checks: Check[] = [
{
name: 'Has PagerDuty on-call',
passed: Boolean(annotations['pagerduty.com/service-id']),
},
{
name: 'SLO defined',
passed: annotations['slo.io/slo-defined'] === 'true',
},
{
name: 'Has runbook',
passed: Boolean(annotations['runbook.io/url']),
},
{
name: 'Has TechDocs',
passed: Boolean(annotations['backstage.io/techdocs-ref']),
},
{
name: 'Kubernetes monitoring enabled',
passed: Boolean(annotations['backstage.io/kubernetes-id']),
},
];
const score = checks.filter(c => c.passed).length;
const total = checks.length;
return (
<Card>
<CardHeader title={`Scorecard: ${score}/${total}`} />
<List>
{checks.map(c => (
<ListItem key={c.name}>
{c.passed ? '✅' : '❌'} {c.name}
</ListItem>
))}
</List>
</Card>
);
}
Scorecard Dimensions (Platform Standards)
| Dimension | Check | Evidence |
|---|---|---|
| Ownership | Has owner group defined | spec.owner in catalog-info.yaml |
| On-Call | Has PagerDuty service | pagerduty.com/service-id annotation |
| Documentation | TechDocs configured | backstage.io/techdocs-ref annotation |
| Documentation | Runbook exists | runbook.io/url annotation |
| Reliability | SLO defined | PrometheusRule with slo labels in Git |
| Observability | Kubernetes monitoring enabled | backstage.io/kubernetes-id annotation |
| Security | Vulnerability scanning in CI | Trivy step in GitHub Actions workflow |
| Security | Image signed | Cosign signature in registry |
| Production readiness | Resource requests/limits set | Kyverno policy require-resource-limits passes |
| Production readiness | PDB configured | PodDisruptionBudget exists in namespace |
10. Onboarding Automation
The full power of Backstage is realised when the Software Template provisions everything a new service needs in one click: Git repo, Kubernetes namespace, Argo CD application, Vault namespace, Slack channel, PagerDuty service, and catalog registration.
New service onboarding (fully automated via Backstage Template):
Developer fills form in Backstage:
- Service name: payment-webhook-handler
- Owner: payments-squad
- System: payments-system
- Needs PostgreSQL: yes
Template steps execute:
1. fetch:template → Render skeleton files
2. publish:github → Create GitHub repo + branch protection
3. github:actions:dispatch → Trigger first CI build
4. argocd:create-resources → Create ArgoCD Application (points at Helm chart)
5. kubernetes:apply → Create namespace + ResourceQuota + LimitRange
6. vault:create-namespace → Create Vault namespace + policy (custom action)
7. http:backstage:request → Create PagerDuty service
8. http:backstage:request → Create Slack channel #payment-webhook-handler
9. catalog:register → Register in Backstage catalog
Developer sees output:
✅ Repository: https://github.com/myorg/payment-webhook-handler
✅ Catalog entry: http://portal/catalog/default/Component/payment-webhook-handler
✅ CI Pipeline: https://github.com/myorg/payment-webhook-handler/actions
Time to first deployment: ~5 minutes (vs 2–3 days of tickets previously)
GitHub App for Backstage (recommended over PAT)
# Using a GitHub App instead of Personal Access Token gives:
# - Per-installation permissions (not tied to a user account)
# - Higher rate limits (5000 req/hr per installation vs 60 anon)
# - Fine-grained repository access control
# - Audit trail in GitHub org settings
# Register a GitHub App with these permissions:
# Repository: contents(rw), pull_requests(rw), workflows(rw), actions(r)
# Organization: members(r)
# app-config.yaml:
integrations:
github:
- host: github.com
apps:
- appId: ${GITHUB_APP_ID}
clientId: ${GITHUB_APP_CLIENT_ID}
clientSecret: ${GITHUB_APP_CLIENT_SECRET}
webhookSecret: ${GITHUB_WEBHOOK_SECRET}
privateKey: ${GITHUB_APP_PRIVATE_KEY}
11. Best Practices
1. Treat Backstage as a product with a roadmap
Assign a dedicated owner (2–3 engineers). Publish a quarterly roadmap. Run user research (NPS surveys, shadowing sessions). The most common failure mode is treating Backstage as an "infra project" with no user feedback loop.
2. Start with the service catalog, add features later
Get every service registered with a catalog-info.yaml in the first quarter. The Kubernetes plugin and TechDocs add value immediately. Templates and scorecards can wait until the catalog is populated and developers use the portal daily.
3. Automate catalog-info.yaml population
Write a migration script that generates catalog-info.yaml for all existing services from your CI/GitHub metadata. Requiring manual PR per team creates adoption friction. Auto-generate first, let teams refine.
4. Use the GitHub App, not a PAT
Personal Access Tokens are tied to a user account. When that employee leaves, Backstage breaks silently. GitHub Apps are organisational, have fine-grained permissions, and don't expire unless you rotate them.
5. Store Backstage config in Git, secrets in Vault/ESO
app-config.yaml goes in Git. All ${VAR} references are secrets in Vault synced by ESO. This gives you version-controlled config with no plaintext credentials. Use a separate app-config.local.yaml for local dev only.
6. Test templates with dry-run before publishing
Use backstage-cli scaffold --dry-run to verify templates generate correct output before making them available to all developers. A broken template for 500 developers is a production incident.
7. Use scorecards to drive platform adoption
Scorecards create a feedback loop: service owners see their score and know exactly what to fix. Tie scorecard results to quarterly platform reviews. Don't mandate — incentivise. "Top scorer" recognition drives more adoption than policy enforcement.
8. Version-pin all Backstage plugins
Backstage and its plugins release frequently. Pin exact versions in package.json and use Renovate to propose upgrades. Never run yarn upgrade in production without testing — breaking API changes between major plugin versions are common.
Coverage Checklist
- Backstage architecture diagram (service catalog / Software Templates / TechDocs / plugins / scorecards)
- Backstage is a framework callout (not SaaS — ownership model and failure mode)
- Backstage architecture: Node.js app + PostgreSQL + GitHub/GitLab + OIDC + S3 for TechDocs
- Backstage Docker image build (yarn install/tsc/build:backend + docker build)
- Helm install command + backstage-values.yaml (replicas, resources, appConfig full YAML, PostgreSQL, ingress)
- appConfig: app/organization/backend/integrations(github)/auth(github+okta)/catalog providers(github with schedule)/techdocs(awsS3)/extraEnvVarsSecret
- backstage-secrets Secret with all environment variable keys
- Entity kinds reference table (Component/API/System/Domain/Group/User/Resource/Location)
- Full catalog-info.yaml Component: all key annotations (source-location/techdocs-ref/github.com/kubernetes-id/argocd/pagerduty/grafana), tags, links, spec (type/lifecycle/owner/system/providesApis/consumesApis/dependsOn)
- API entity YAML (openapi type, definition $text reference)
- System + Domain YAML
- Resource entity YAML (database type, AWS ARN annotation, dependencyOf)
- Group + User YAML (profile, parent hierarchy, members)
- Software Template skeleton directory structure
- Full Template YAML: parameters (3 pages with OwnerPicker/EntityPicker/RepoUrlPicker/booleans), all 7 steps (fetch:template/publish:github/catalog:register/argocd:create-resources/kubernetes:apply/http:backstage:request/notify-slack), output links
- Template skeleton catalog-info.yaml and values.yaml with Nunjucks variables
- Built-in template actions reference table (11 actions)
- Custom action implementation: vault:create-namespace TypeScript class (createTemplateAction schema + async handler with Vault SDK)
- TechDocs mkdocs.yml structure (techdocs-core plugin, nav hierarchy)
- TechDocs CI workflow (techdocs-cli generate + publish to S3 with OIDC auth)
- TechDocs app config (external builder, awsS3 publisher, roleArn)
- Kubernetes plugin app-config (multiTenant serviceLocator, config cluster list with serviceAccount auth, customResources for Rollouts)
- Kubernetes plugin catalog-info annotation (kubernetes-id, namespace, label-selector)
- Argo CD plugin config + catalog-info annotation
- GitHub Actions plugin config
- Plugin ecosystem table (12 plugins with provider and description)
- Scorecard metadata annotations (pagerduty, slo, security-scan, runbook, compliance-tier)
- Custom Scorecard React component (useEntity hook, checks array, score/total calculation)
- Scorecard dimensions reference table (10 checks across ownership/on-call/docs/reliability/observability/security/production-readiness)
- Full onboarding automation flow diagram (9 template steps → 5-minute time-to-deploy vs 2-3 days tickets)
- GitHub App setup (permissions required, app-config.yaml with appId/privateKey/webhookSecret)
- 8 best practices cards