LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Internal Developer Platforms with Backstage: Building the Golden Path

backstageplatform-engineeringdeveloper-experiencedevopskubernetesidp

Every engineering organization above a certain size hits the same wall: too many services, too many tools, and tribal knowledge scattered across Slack threads, outdated wikis, and the heads of the three engineers who were there at the beginning. Onboarding takes weeks. Finding who owns a service requires asking around. Running a new microservice means copying an old one and hoping you got all the Dockerfile, CI pipeline, Helm chart, and monitoring configuration right.

Backstage is Spotify’s answer to this problem, now a CNCF graduated project. It’s a framework for building Internal Developer Platforms (IDPs): a single pane of glass for your software catalog, self-service infrastructure provisioning, documentation, and developer workflows. Done well, it turns “how do I start a new service?” from a week-long process into a fifteen-minute one.

This guide walks through deploying Backstage, building a real software catalog, creating golden-path scaffolding templates, publishing TechDocs, and wiring up the plugins that make it genuinely useful.

What Problem Backstage Solves

Before diving into setup, it’s worth being precise about what Backstage is and isn’t.

Backstage is not a deployment tool, a CI/CD platform, or a monitoring system. It’s a developer portal — an aggregator and organizer that surfaces information from your existing tools in one place. It answers questions like:

  • What services exist and who owns them?
  • What’s the API contract for the payments service?
  • How do I spin up a new Python microservice with all our standards baked in?
  • Where are the runbooks for the auth service?
  • What’s the current build status of the checkout service?
  • Which services depend on the Redis cluster I’m about to upgrade?

The value compounds with adoption: the more teams register their services, templates, and documentation, the more useful the portal becomes for everyone.

Core Concepts

Software Catalog: A registry of all your software components — services, libraries, websites, pipelines, infrastructure. Each entity is described by a catalog-info.yaml file in the component’s repository.

Software Templates (Scaffolding): Parameterized project templates that generate a complete, standards-compliant repository when a developer fills out a form. The “golden path” — the blessed way to create a new service.

TechDocs: Markdown documentation living alongside code, automatically published and searchable through Backstage.

Plugins: Backstage’s extensibility model. The core is minimal; almost everything interesting comes from plugins (GitHub Actions, Kubernetes, PagerDuty, Argo CD, Datadog, etc.).

Deploying Backstage

Prerequisites

1
2
node --version  # >= 20
yarn --version  # >= 4

Creating a New Backstage App

1
2
3
4
5
npx @backstage/create-app@latest --name my-backstage

cd my-backstage
yarn install
yarn dev  # Starts on http://localhost:3000

The scaffolded app has two packages: packages/app (the React frontend) and packages/backend (the Node.js backend).

Docker Compose for Self-Hosting

For production, run Backstage with a PostgreSQL backend instead of the default in-memory SQLite:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# docker-compose.yml
version: '3.8'

services:
  backstage:
    image: my-backstage:latest
    build:
      context: .
      dockerfile: packages/backend/Dockerfile
    ports:
      - "7007:7007"
    environment:
      POSTGRES_HOST: postgres
      POSTGRES_PORT: 5432
      POSTGRES_USER: backstage
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      GITHUB_TOKEN: ${GITHUB_TOKEN}
      AUTH_GITHUB_CLIENT_ID: ${AUTH_GITHUB_CLIENT_ID}
      AUTH_GITHUB_CLIENT_SECRET: ${AUTH_GITHUB_CLIENT_SECRET}
      APP_CONFIG_app_baseUrl: "https://backstage.yourcompany.com"
      APP_CONFIG_backend_baseUrl: "https://backstage.yourcompany.com"
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: backstage
      POSTGRES_USER: backstage
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U backstage"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  postgres-data:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# packages/backend/Dockerfile
FROM node:20-bookworm-slim AS build

WORKDIR /app
COPY . .
RUN yarn install --frozen-lockfile
RUN yarn tsc
RUN yarn build:backend --config ../../app-config.yaml

FROM node:20-bookworm-slim

WORKDIR /app
COPY --from=build /app/packages/backend/dist/bundle.tar.gz .
RUN tar -xzf bundle.tar.gz && rm bundle.tar.gz
RUN yarn install --frozen-lockfile --production

CMD ["node", "packages/backend", "--config", "app-config.yaml"]

Core Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# app-config.yaml
app:
  title: "Acme Developer Portal"
  baseUrl: ${APP_BASE_URL}

organization:
  name: Acme Corp

backend:
  baseUrl: ${BACKEND_BASE_URL}
  listen:
    port: 7007
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: ${POSTGRES_PORT}
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
      database: backstage_plugin_catalog

auth:
  environment: production
  providers:
    github:
      production:
        clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
        signIn:
          resolvers:
            - resolver: usernameMatchingUserEntityName

integrations:
  github:
    - host: github.com
      token: ${GITHUB_TOKEN}

catalog:
  providers:
    githubOrg:
      id: production
      githubUrl: https://github.com
      orgs: ['your-org']
  locations:
    # Seed with some example entities
    - type: url
      target: https://github.com/your-org/backstage-catalog/blob/main/all.yaml
      rules:
        - allow: [Component, System, API, Group, User, Resource, Location]

techdocs:
  builder: external
  generator:
    runIn: docker
  publisher:
    type: awsS3
    awsS3:
      bucketName: ${TECHDOCS_S3_BUCKET}
      region: us-east-1

scaffolder:
  defaultAuthor:
    name: Backstage Scaffolder
    email: scaffolder@yourcompany.com

proxy:
  '/pagerduty':
    target: https://api.pagerduty.com
    headers:
      Authorization: Token token=${PAGERDUTY_TOKEN}

Building the Software Catalog

The catalog is the foundation everything else builds on. Each service, library, or resource registers itself with a catalog-info.yaml file.

Registering a Service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# catalog-info.yaml (in your service repository root)
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: payments-api
  title: Payments API
  description: Handles all payment processing including charges, refunds, and webhooks
  annotations:
    # GitHub integration — shows CI status, PRs, contributors
    github.com/project-slug: your-org/payments-api

    # Kubernetes integration — shows pod status, logs
    backstage.io/kubernetes-id: payments-api
    backstage.io/kubernetes-namespace: production

    # ArgoCD integration
    argocd/app-name: payments-api

    # PagerDuty integration — shows active incidents
    pagerduty.com/integration-key: ${PAGERDUTY_PAYMENTS_KEY}

    # TechDocs location
    backstage.io/techdocs-ref: dir:.

    # Runbook URL
    runbook-url: https://wiki.yourcompany.com/runbooks/payments-api

  tags:
    - payments
    - critical
    - go

  links:
    - url: https://grafana.yourcompany.com/d/payments
      title: Grafana Dashboard
      icon: dashboard
    - url: https://payments-api.yourcompany.com/api/docs
      title: API Documentation
      icon: docs

spec:
  type: service
  lifecycle: production
  owner: group:payments-team
  system: checkout-system

  # API this service provides
  providesApis:
    - payments-api-v2

  # APIs this service consumes
  consumesApis:
    - fraud-detection-api
    - notifications-api

  # Other components this service depends on
  dependsOn:
    - resource:payments-postgres
    - resource:payments-redis
    - component:fraud-detection-service

Defining Systems and Domains

Group related services into Systems, and Systems into Domains:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# systems/checkout-system.yaml
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
  name: checkout-system
  description: All services involved in the checkout flow
  tags: [checkout, critical]
spec:
  owner: group:platform-team
  domain: ecommerce

---
apiVersion: backstage.io/v1alpha1
kind: Domain
metadata:
  name: ecommerce
  description: E-commerce platform components
spec:
  owner: group:engineering-leadership

Defining APIs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# apis/payments-api-v2.yaml
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
  name: payments-api-v2
  description: Payment processing REST API
  tags: [payments, rest]
spec:
  type: openapi
  lifecycle: production
  owner: group:payments-team
  system: checkout-system
  definition:
    $text: https://payments-api.yourcompany.com/openapi.yaml
    # Or inline:
    # $text: |
    #   openapi: 3.0.0
    #   info:
    #     title: Payments API
    #     ...

Defining Resources

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# resources/payments-postgres.yaml
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
  name: payments-postgres
  description: PostgreSQL database for payments service
  tags: [database, postgresql]
spec:
  type: database
  owner: group:platform-team
  system: checkout-system

Auto-Discovery with GitHub Provider

Instead of manually registering every service, configure Backstage to auto-discover catalog-info.yaml files across your GitHub organization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// packages/backend/src/index.ts
import { GithubEntityProvider } from '@backstage/plugin-catalog-backend-module-github';

// In your catalog builder:
const catalogBuilder = await CatalogBuilder.create(env);

catalogBuilder.addEntityProvider(
  GithubEntityProvider.fromConfig(env.config, {
    logger: env.logger,
    scheduler: env.scheduler,
    schedule: env.scheduler.createScheduledTaskRunner({
      frequency: { minutes: 30 },
      timeout: { minutes: 3 },
    }),
  }),
);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# app-config.yaml — discovery rules
catalog:
  providers:
    github:
      your-org:
        organization: 'your-org'
        catalogPath: '/catalog-info.yaml'
        filters:
          branch: 'main'
          repository: '.*'   # All repos; use regex to filter
        schedule:
          frequency: { minutes: 30 }
          timeout: { minutes: 3 }

Now any repository that adds a catalog-info.yaml appears in the catalog automatically within 30 minutes.

Software Templates: The Golden Path

Templates are where Backstage pays for itself. A well-designed template creates a new service with everything your organization requires: CI pipeline, Dockerfile, Helm chart, monitoring dashboards, runbook stub, and catalog registration — all pre-configured to your standards.

A Complete Service Template

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# templates/go-microservice/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: go-microservice
  title: Go Microservice
  description: Creates a production-ready Go microservice with CI/CD, monitoring, and Kubernetes deployment
  tags:
    - recommended
    - go
    - kubernetes

spec:
  owner: group:platform-team
  type: service

  # Input form shown to developers
  parameters:
    - title: Service Details
      required: [name, description, owner]
      properties:
        name:
          title: Service Name
          type: string
          description: Lowercase, hyphen-separated (e.g., payment-processor)
          pattern: '^[a-z][a-z0-9-]*$'
          ui:autofocus: true

        description:
          title: Description
          type: string
          description: What does this service do? (shown in the catalog)

        owner:
          title: Owner
          type: string
          description: Team that owns this service
          ui:field: OwnerPicker
          ui:options:
            allowedKinds: [Group]

        system:
          title: System
          type: string
          description: Which system does this service belong to?
          ui:field: EntityPicker
          ui:options:
            catalogFilter:
              kind: System

    - title: Infrastructure
      required: [namespace, replicaCount]
      properties:
        namespace:
          title: Kubernetes Namespace
          type: string
          default: production
          enum: [production, staging, development]

        replicaCount:
          title: Initial Replica Count
          type: integer
          default: 2
          minimum: 1
          maximum: 10

        enableDatabase:
          title: PostgreSQL Database
          type: boolean
          default: false
          description: Provision a PostgreSQL database for this service

        enableRedis:
          title: Redis Cache
          type: boolean
          default: false

    - title: Repository
      required: [repoUrl]
      properties:
        repoUrl:
          title: Repository Location
          type: string
          ui:field: RepoUrlPicker
          ui:options:
            allowedHosts: [github.com]
            allowedOwners: [your-org]

  # Actions that execute when the form is submitted
  steps:
    - id: fetch-template
      name: Fetch Template Files
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
          description: ${{ parameters.description }}
          owner: ${{ parameters.owner }}
          system: ${{ parameters.system }}
          namespace: ${{ parameters.namespace }}
          replicaCount: ${{ parameters.replicaCount }}
          enableDatabase: ${{ parameters.enableDatabase }}
          enableRedis: ${{ parameters.enableRedis }}
          repoName: ${{ (parameters.repoUrl | parseRepoUrl).repo }}
          orgName: ${{ (parameters.repoUrl | parseRepoUrl).owner }}

    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        allowedHosts: [github.com]
        description: ${{ parameters.description }}
        repoUrl: ${{ parameters.repoUrl }}
        defaultBranch: main
        repoVisibility: private
        topics:
          - go
          - microservice
          - ${{ parameters.system }}

    - id: register
      name: Register in Catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml

    # Optionally open a PR to the infrastructure repo for database/redis provisioning
    - id: create-infra-pr
      if: ${{ parameters.enableDatabase or parameters.enableRedis }}
      name: Create Infrastructure PR
      action: publish:github:pull-request
      input:
        repoUrl: github.com?repo=infrastructure&owner=your-org
        title: "Provision resources for ${{ parameters.name }}"
        branchName: provision-${{ parameters.name }}
        description: |
          Auto-generated by Backstage scaffolder.
          Service: ${{ parameters.name }}
          Database: ${{ parameters.enableDatabase }}
          Redis: ${{ parameters.enableRedis }}
        targetPath: services/${{ parameters.name }}

  output:
    links:
      - title: Repository
        url: ${{ steps.publish.output.remoteUrl }}
      - title: Open in Catalog
        icon: catalog
        entityRef: ${{ steps.register.output.entityRef }}
      - title: Infrastructure PR
        url: ${{ steps['create-infra-pr'].output.remoteUrl }}
        if: ${{ parameters.enableDatabase or parameters.enableRedis }}

Template Skeleton Files

The skeleton directory contains the actual files with template variables:

templates/go-microservice/skeleton/
├── catalog-info.yaml
├── Dockerfile
├── Makefile (or Taskfile.yml)
├── README.md
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── config/
│   └── server/
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── helm/
│   └── ${{ values.name }}/
│       ├── Chart.yaml
│       ├── values.yaml
│       └── templates/
│           ├── deployment.yaml
│           ├── service.yaml
│           ├── ingress.yaml
│           └── hpa.yaml
├── docs/
│   ├── mkdocs.yml
│   └── index.md
└── .backstage/
    └── catalog-info.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# skeleton/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: ${{ values.name }}
  title: ${{ values.name | title }}
  description: ${{ values.description }}
  annotations:
    github.com/project-slug: ${{ values.orgName }}/${{ values.repoName }}
    backstage.io/kubernetes-id: ${{ values.name }}
    backstage.io/techdocs-ref: dir:.
  tags:
    - go
    - ${{ values.system }}
spec:
  type: service
  lifecycle: experimental
  owner: ${{ values.owner }}
  system: ${{ values.system }}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// skeleton/cmd/server/main.go
package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/${{ values.orgName }}/${{ values.repoName }}/internal/config"
	"github.com/${{ values.orgName }}/${{ values.repoName }}/internal/server"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))
	slog.SetDefault(logger)

	cfg, err := config.Load()
	if err != nil {
		slog.Error("failed to load config", "error", err)
		os.Exit(1)
	}

	srv := server.New(cfg, logger)

	httpServer := &http.Server{
		Addr:         fmt.Sprintf(":%d", cfg.Port),
		Handler:      srv,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	go func() {
		slog.Info("starting server", "port", cfg.Port, "service", "${{ values.name }}")
		if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("server error", "error", err)
			os.Exit(1)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	if err := httpServer.Shutdown(ctx); err != nil {
		slog.Error("server shutdown error", "error", err)
	}
	slog.Info("server stopped")
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# skeleton/.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

env:
  SERVICE_NAME: ${{ values.name }}
  REGISTRY: ghcr.io
  IMAGE: ghcr.io/${{ values.orgName }}/${{ values.repoName }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
          cache: true
      - run: go test -race -coverprofile=coverage.out ./...
      - uses: codecov/codecov-action@v4

  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ "{{" }} env.REGISTRY }}
          username: ${{ "{{" }} github.actor }}
          password: ${{ "{{" }} secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: ${{ "{{" }} github.ref == 'refs/heads/main' }}
          tags: |
            ${{ "{{" }} env.IMAGE }}:latest
            ${{ "{{" }} env.IMAGE }}:${{ "{{" }} github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-helm@v4
      - name: Deploy to staging
        run: |
          helm upgrade --install ${{ values.name }} ./helm/${{ values.name }} \
            --namespace staging \
            --set image.tag=${{ "{{" }} github.sha }} \
            --wait

TechDocs: Documentation as Code

TechDocs renders Markdown documentation from your service repositories and makes it searchable in Backstage. Developers write docs alongside code; Backstage publishes them automatically.

Setup

Add TechDocs configuration to each service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# docs/mkdocs.yml
site_name: "${{ values.name }} Documentation"
site_description: "${{ values.description }}"
repo_url: https://github.com/your-org/${{ values.repoName }}
edit_uri: edit/main/docs/

nav:
  - Home: index.md
  - Architecture: architecture.md
  - API Reference: api.md
  - Runbooks:
    - Overview: runbooks/index.md
    - Incident Response: runbooks/incident-response.md
    - Deployment: runbooks/deployment.md
  - ADRs:
    - ADR-001 Database Choice: adrs/001-database-choice.md

plugins:
  - techdocs-core

markdown_extensions:
  - admonition
  - pymdownx.highlight
  - pymdownx.superfences
  - pymdownx.details
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- docs/index.md -->
# Payments API

The Payments API handles all payment processing including charges, refunds, and webhook delivery.

## Quick Reference

| Item | Value |
|------|-------|
| Owner | [Payments Team](../../group/payments-team) |
| On-Call | [PagerDuty](https://yourcompany.pagerduty.com/service-directory/payments-api) |
| Grafana | [Dashboard](https://grafana.yourcompany.com/d/payments) |
| SLO | 99.9% availability, p99 < 500ms |

## Getting Started

```bash
# Clone and run locally
git clone git@github.com:your-org/payments-api.git
cd payments-api
task infra:up   # Start local dependencies
task dev        # Start with hot reload

Architecture

See Architecture for a detailed overview.


### TechDocs with S3 Publishing

For production, pre-build and publish TechDocs to S3 rather than building on-demand:

```yaml
# .github/workflows/techdocs.yml
name: Publish TechDocs

on:
  push:
    branches: [main]
    paths:
      - docs/**
      - mkdocs.yml
      - catalog-info.yaml

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install TechDocs CLI
        run: pip install mkdocs-techdocs-core

      - name: Build and publish docs
        env:
          TECHDOCS_S3_BUCKET: ${{ secrets.TECHDOCS_S3_BUCKET }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          npx @techdocs/cli generate --no-docker
          npx @techdocs/cli publish \
            --publisher-type awsS3 \
            --storage-name $TECHDOCS_S3_BUCKET \
            --entity default/Component/payments-api

Essential Plugins

Backstage’s plugin ecosystem is what makes it genuinely useful. Here are the plugins worth installing early.

Kubernetes Plugin

Shows pod status, deployments, and recent events for each service directly in Backstage:

1
2
yarn --cwd packages/app add @backstage/plugin-kubernetes
yarn --cwd packages/backend add @backstage/plugin-kubernetes-backend
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# app-config.yaml
kubernetes:
  serviceLocatorMethod:
    type: multiTenant
  clusterLocatorMethods:
    - type: config
      clusters:
        - url: ${K8S_API_URL}
          name: production
          authProvider: serviceAccount
          serviceAccountToken: ${K8S_SERVICE_ACCOUNT_TOKEN}
          caData: ${K8S_CA_DATA}
          skipTLSVerify: false

        - url: ${K8S_STAGING_API_URL}
          name: staging
          authProvider: serviceAccount
          serviceAccountToken: ${K8S_STAGING_TOKEN}

GitHub Actions Plugin

Inline CI/CD status without leaving Backstage:

1
yarn --cwd packages/app add @backstage-community/plugin-github-actions
1
2
3
4
5
6
7
// packages/app/src/components/catalog/EntityPage.tsx
import { EntityGithubActionsContent } from '@backstage-community/plugin-github-actions';

// Add to the service entity page:
<EntityLayout.Route path="/ci-cd" title="CI/CD">
  <EntityGithubActionsContent />
</EntityLayout.Route>

Argo CD Plugin

1
yarn --cwd packages/app add @roadiehq/backstage-plugin-argo-cd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# app-config.yaml
argocd:
  username: ${ARGOCD_USERNAME}
  password: ${ARGOCD_PASSWORD}
  appLocatorMethods:
    - type: config
      instances:
        - name: production
          url: https://argocd.yourcompany.com
          token: ${ARGOCD_TOKEN}

PagerDuty Plugin

Surface active incidents and on-call info:

1
2
yarn --cwd packages/app add @pagerduty/backstage-plugin
yarn --cwd packages/backend add @pagerduty/backstage-plugin-backend
1
2
3
4
5
6
7
// packages/app/src/components/catalog/EntityPage.tsx
import { EntityPagerDutyCard } from '@pagerduty/backstage-plugin';

// Add to service overview:
<Grid item md={6}>
  <EntityPagerDutyCard />
</Grid>

Cost Insights Plugin

Connect AWS/GCP cost data to service ownership:

1
yarn --cwd packages/app add @backstage/plugin-cost-insights

Search Plugin

Enable cross-catalog full-text search:

1
2
3
4
5
6
7
8
9
// packages/backend/src/index.ts
import { SearchIndex } from '@backstage/plugin-search-backend-node';
import { ElasticSearchSearchEngine } from '@backstage/plugin-search-backend-module-elasticsearch';

// Configure search engine (or use the included Lunr for small installations)
const searchEngine = await ElasticSearchSearchEngine.fromConfig({
  logger,
  config,
});

Custom Plugins

Backstage’s real power is its extensibility. You can build internal plugins that surface data from your specific tools — internal deployment systems, cost management platforms, compliance dashboards.

1
2
3
# Generate a new plugin scaffold
yarn backstage-cli new --select plugin
# Enter name: my-deployment-status
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// plugins/my-deployment-status/src/components/DeploymentStatusCard.tsx
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { InfoCard, Progress, StatusOK, StatusError } from '@backstage/core-components';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';

export const DeploymentStatusCard = () => {
  const { entity } = useEntity();
  const configApi = useApi(configApiRef);
  const backendUrl = configApi.getString('backend.baseUrl');

  const serviceName = entity.metadata.name;

  const { loading, value: deployment, error } = useAsync(async () => {
    const resp = await fetch(
      `${backendUrl}/api/proxy/deployments/${serviceName}/status`
    );
    if (!resp.ok) throw new Error('Failed to fetch deployment status');
    return resp.json();
  }, [serviceName]);

  if (loading) return <Progress />;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <InfoCard title="Deployment Status">
      <table>
        <tbody>
          <tr>
            <td>Production</td>
            <td>
              {deployment.production.healthy
                ? <StatusOK>v{deployment.production.version}</StatusOK>
                : <StatusError>Degraded</StatusError>
              }
            </td>
          </tr>
          <tr>
            <td>Staging</td>
            <td>
              {deployment.staging.healthy
                ? <StatusOK>v{deployment.staging.version}</StatusOK>
                : <StatusError>Degraded</StatusError>
              }
            </td>
          </tr>
        </tbody>
      </table>
    </InfoCard>
  );
};

Driving Adoption

Technical setup is the easy part. Getting engineers to actually use Backstage — and keep it up to date — is the real challenge.

The Chicken-and-Egg Problem

Backstage is only useful when the catalog is complete and accurate. But engineers won’t register their services until Backstage is useful. Break this cycle by:

Making registration the path of least resistance: If your golden-path templates auto-register services in the catalog, every new service starts cataloged. Over time, the ratio of cataloged to uncatalogued services improves naturally.

Running a catalog sprint: Dedicate one sprint to registering all existing services. Make it a team event with clear criteria: every service needs an owner, a description, and at least one annotation pointing to its Kubernetes workload.

Linking to Backstage from existing tools: Add a “View in Backstage” link to your Slack alerts, PagerDuty incidents, and CI failure notifications. Engineers encounter Backstage in the tools they already use.

Measuring Success

Track these metrics to demonstrate value:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# What "good" looks like after 6 months
catalog_completeness:
  services_registered: ">= 90%"
  services_with_owner: ">= 95%"
  services_with_techdocs: ">= 70%"
  apis_documented: ">= 80%"

developer_experience:
  time_to_first_service: "< 30 minutes"  # Was: 2-5 days
  onboarding_time: "< 1 week"            # Was: 2-4 weeks
  incident_mean_time_to_owner: "< 5 min" # Was: 30+ min (searching Slack)

template_usage:
  services_created_via_template: ">= 80%"
  templates_available: ">= 5"

Keeping the Catalog Accurate

Stale data is worse than no data — it erodes trust in the portal. Automate accuracy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# GitHub Actions workflow to detect orphaned catalog entries
# (services in catalog that no longer have a repository)
name: Catalog Audit

on:
  schedule:
    - cron: '0 9 * * 1'  # Every Monday morning

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - name: Find orphaned catalog entries
        run: |
          # Query Backstage API for all components
          curl -H "Authorization: Bearer ${BACKSTAGE_TOKEN}" \
            "https://backstage.yourcompany.com/api/catalog/entities?filter=kind=Component" \
            | jq -r '.[].metadata.annotations["github.com/project-slug"]' \
            | while read slug; do
                if ! gh repo view "$slug" &>/dev/null; then
                  echo "ORPHANED: $slug"
                fi
              done

Production Hardening

High Availability

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# kubernetes/backstage-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backstage
  namespace: backstage
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: backstage
  template:
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - topologyKey: kubernetes.io/hostname
              labelSelector:
                matchLabels:
                  app: backstage
      containers:
        - name: backstage
          image: your-registry/backstage:latest
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              cpu: 2
              memory: 2Gi
          readinessProbe:
            httpGet:
              path: /healthcheck
              port: 7007
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /healthcheck
              port: 7007
            initialDelaySeconds: 60
            periodSeconds: 30

Security Hardening

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# app-config.production.yaml
auth:
  # Require authentication — no anonymous access
  dangerouslyDisableDefaultAuthPolicy: false

backend:
  auth:
    externalAccess:
      # Service-to-service tokens for CI/CD automation
      - type: static
        options:
          token: ${BACKSTAGE_CI_TOKEN}
          subject: ci-cd-pipeline

  # CSRF protection
  csp:
    connect-src: ["'self'", 'https:']
    img-src: ["'self'", 'data:', 'https:']
    script-src: ["'self'"]

The Platform Team’s Playbook

A Backstage deployment is only as good as the platform team maintaining it. Here’s a practical operating model:

Week 1-2: Deploy Backstage, enable GitHub SSO, register 10 key services manually as examples.

Week 3-4: Build your first template (the most common service type). Announce it to engineering. Run office hours.

Month 2: Catalog sprint — get all teams to register their services. Add Kubernetes and GitHub Actions plugins.

Month 3: TechDocs rollout. Require new services to have docs before going to production.

Month 4+: Build internal plugins for your specific tools. Automate catalog audits. Measure adoption and iterate.

The platform team should treat Backstage like a product: have a roadmap, collect developer feedback, run regular user interviews, and ship improvements on a cadence.

Conclusion

Backstage transforms the invisible infrastructure of your engineering organization into something visible, navigable, and self-service. The software catalog answers “what exists and who owns it?” Templates answer “how do I build new things the right way?” TechDocs answers “how does this thing work?”

The ROI compounds: every hour saved onboarding a new engineer, every incident resolved faster because the on-call could find the runbook immediately, every new service that launched with correct monitoring because it was baked into the template rather than bolted on later.

The technical setup is straightforward. The organizational work — getting buy-in, driving adoption, keeping data accurate — is harder and more important. Start with the services your platform team owns, make them exemplary catalog entries, and let the quality speak for itself.

A portal that saves one engineer two hours a week justifies its existence. One that saves an entire engineering organization that time every week is transformative infrastructure.

Comments