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 is not a product — it is a framework

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

KindDescriptionExample
ComponentA software component (service, library, website, tool)order-service, payment-sdk
APIAn API exposed by a Component (OpenAPI, AsyncAPI, GraphQL, gRPC)Order API v2 (OpenAPI spec)
SystemA collection of related Components and APIsorder-management-system
DomainA business domain grouping related Systemsecommerce, payments
GroupA team or organisational unitplatform-team, order-squad
UserAn individual developer (synced from identity provider)alice@example.com
ResourceInfrastructure resources (databases, S3 buckets, queues)orders-rds-postgres, payments-sqs
LocationA pointer to other catalog-info.yaml filesall-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

ActionDescription
fetch:templateCopy and render skeleton directory with Nunjucks values
fetch:plainCopy plain files without templating
publish:githubCreate GitHub repo, push files, configure branch protection
publish:gitlabCreate GitLab project and push files
catalog:registerRegister the new entity in the Backstage catalog
catalog:writeWrite a catalog entity to disk before publishing
github:actions:dispatchTrigger a GitHub Actions workflow
argocd:create-resourcesCreate Argo CD Application and AppProject
kubernetes:applyApply manifest to a registered Kubernetes cluster
http:backstage:requestMake HTTP request to a backend proxy endpoint
debug:logLog 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

PluginProviderWhat It Shows
@backstage/plugin-kubernetesCorePod status, deployments, rollouts per service
@roadiehq/backstage-plugin-argo-cdRoadieArgo CD app sync status, history
@backstage/plugin-github-actionsCoreCI workflow runs and status
@backstage/plugin-pagerdutyCoreOn-call schedule, active incidents
@roadiehq/backstage-plugin-grafanaRoadieEmbedded Grafana dashboards and alerts
@backstage/plugin-cost-insightsCoreCloud cost per team/service
@backstage/plugin-sonarqubeCoreCode quality metrics and issues
@backstage/plugin-vaultCommunityVault secret paths per service
@backstage/plugin-todoCoreTODO/FIXME comments in source code
@backstage/plugin-tech-radarCoreTechnology adoption radar (ADOPT/TRIAL/HOLD)
@backstage/plugin-searchCoreFull-text search across catalog, TechDocs, APIs
@backstage/plugin-lighthouseCoreLighthouse 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)

DimensionCheckEvidence
OwnershipHas owner group definedspec.owner in catalog-info.yaml
On-CallHas PagerDuty servicepagerduty.com/service-id annotation
DocumentationTechDocs configuredbackstage.io/techdocs-ref annotation
DocumentationRunbook existsrunbook.io/url annotation
ReliabilitySLO definedPrometheusRule with slo labels in Git
ObservabilityKubernetes monitoring enabledbackstage.io/kubernetes-id annotation
SecurityVulnerability scanning in CITrivy step in GitHub Actions workflow
SecurityImage signedCosign signature in registry
Production readinessResource requests/limits setKyverno policy require-resource-limits passes
Production readinessPDB configuredPodDisruptionBudget 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