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}
|
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'"]
|
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