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

Developer Portals with Backstage: Catalog, Scaffolding, TechDocs, and Plugins

backstagedeveloper-experienceplatform-engineeringidpdevopsdocumentationkubernetes

Backstage is an open-source developer portal framework built by Spotify and donated to the CNCF. It started as Spotify’s internal tool for managing thousands of microservices — a single place where any engineer could find what services existed, who owned them, where their documentation lived, and how to create a new one. Backstage was open-sourced in 2020 and is now the de facto standard for building internal developer portals.

At its core, Backstage solves the “where does this live?” problem that plagues growing engineering organizations. Services scatter across GitHub repos, documentation hides in Confluence, runbooks live in Notion, and nobody knows what depends on what. Backstage pulls all of this into a single, searchable, maintained catalog — and builds self-service tooling on top of it.

This guide covers the four pillars of a production Backstage deployment: the Software Catalog, Software Templates (scaffolding), TechDocs (documentation as code), and Plugins (extending the platform).


Architecture Overview

Backstage is a React frontend with a Node.js backend. Its key concepts:

┌─────────────────────────────────────────────────────────┐
│                    Backstage App                         │
│                                                         │
│  ┌─────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │   Catalog   │  │  Scaffolder  │  │   TechDocs    │  │
│  │  (registry) │  │  (templates) │  │  (docs site)  │  │
│  └─────────────┘  └──────────────┘  └───────────────┘  │
│                                                         │
│  ┌─────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │   Plugins   │  │  Auth / RBAC │  │  Search       │  │
│  └─────────────┘  └──────────────┘  └───────────────┘  │
│                                                         │
│  Backend API ←→ Database (PostgreSQL)                   │
│              ←→ External integrations (GitHub, PD, etc) │
└─────────────────────────────────────────────────────────┘

Entities are the objects in Backstage’s catalog: services (Component), APIs, teams (Group), people (User), systems, domains, and resources. Entities are defined in catalog-info.yaml files that live alongside the code they describe.

Plugins extend the frontend and backend. There are 200+ community plugins for Kubernetes, PagerDuty, GitHub, SonarQube, Datadog, ArgoCD, Vault, and dozens more.


Getting Started

Creating a Backstage App

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Requires Node.js 18+ and yarn
npx @backstage/create-app@latest

# Prompts for app name (e.g., "lunarops-portal")
# Creates a fully functional Backstage app in ./lunarops-portal/

cd lunarops-portal

# Start in development mode
yarn dev
# Opens http://localhost:3000

The generated structure:

lunarops-portal/
├── app-config.yaml          # main configuration
├── app-config.local.yaml    # local overrides (gitignored)
├── packages/
│   ├── app/                 # React frontend
│   │   ├── src/
│   │   │   ├── App.tsx      # plugin registration, routes
│   │   │   └── components/  # custom components
│   │   └── package.json
│   └── backend/             # Node.js backend
│       ├── src/
│       │   └── index.ts     # backend plugin registration
│       └── package.json
└── plugins/                 # local custom plugins

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
# app-config.yaml
app:
  title: LunarOps Developer Portal
  baseUrl: http://localhost:3000

backend:
  baseUrl: http://localhost:7007
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: ${POSTGRES_PORT}
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
      database: backstage

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

auth:
  providers:
    github:
      development:
        clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}

catalog:
  # Auto-discover catalog-info.yaml files from GitHub
  providers:
    github:
      myorg:
        organization: 'lunarops'
        catalogPath: '/catalog-info.yaml'
        filters:
          branch: 'main'
          repository: '.*'   # all repos
        schedule:
          frequency: { minutes: 30 }
          timeout: { minutes: 3 }

  rules:
    - allow: [Component, API, Group, User, Resource, System, Domain, Template, Location]

techdocs:
  builder: 'local'
  generator:
    runIn: 'local'
  publisher:
    type: 'local'

# For production: store TechDocs in object storage
# techdocs:
#   builder: 'external'
#   publisher:
#     type: 'googleGcs'
#     googleGcs:
#       bucketName: lunarops-techdocs
#       credentials: ${GOOGLE_APPLICATION_CREDENTIALS}

The Software Catalog

The catalog is Backstage’s core value. Every service, API, library, website, team, and person has an entity. Entities are described in catalog-info.yaml files that live in the repository they describe — the catalog is always in sync with the code.

Entity Kinds

 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
# catalog-info.yaml — a Component (a service, library, or website)
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: my-api
  title: My API Service
  description: REST API for order management
  annotations:
    github.com/project-slug: lunarops/my-api
    backstage.io/techdocs-ref: dir:.
    pagerduty.com/service-id: P1234567
    argocd/app-name: my-api-production
    sonarqube.org/project-key: my-api
  tags:
    - go
    - rest-api
    - orders
  links:
    - url: https://my-api.lunarops.io
      title: Production
      icon: web
    - url: https://grafana.internal/d/my-api
      title: Dashboard
      icon: dashboard
    - url: https://wiki.internal/runbooks/my-api
      title: Runbook
      icon: help
spec:
  type: service          # service | library | website | documentation
  lifecycle: production  # experimental | production | deprecated
  owner: orders-team
  system: order-management
  dependsOn:
    - component:default/postgres-orders
    - component:default/redis-cache
  providesApis:
    - my-api-v1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# An API entity — describes the API contract
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
  name: my-api-v1
  description: Order Management API v1
spec:
  type: openapi
  lifecycle: production
  owner: orders-team
  system: order-management
  definition:
    $text: ./openapi.yaml   # reference to OpenAPI spec file
1
2
3
4
5
6
7
8
9
# A System — groups related components
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
  name: order-management
  description: Everything related to order processing
spec:
  owner: orders-team
  domain: commerce
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# A Group — a team
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
  name: orders-team
  description: The order management engineering team
spec:
  type: team
  profile:
    displayName: Orders Team
    email: orders-team@lunarops.io
    picture: https://avatars.githubusercontent.com/t/12345
  parent: engineering
  children: []
  members:
    - alice
    - bob
    - carol

Auto-Discovery with GitHub Provider

Rather than registering each service manually, the GitHub provider scans your organization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# app-config.yaml
catalog:
  providers:
    github:
      lunarops:
        organization: 'lunarops'
        catalogPath: '/catalog-info.yaml'   # look for this file in every repo
        filters:
          branch: 'main'
          repository: '^(?!\.github).*'     # exclude .github repo
        schedule:
          frequency: { minutes: 30 }
          timeout: { minutes: 5 }
          initialDelay: { seconds: 15 }

Any repo that adds a catalog-info.yaml file to main is automatically discovered and added to the catalog within 30 minutes.

Dependency Visualization

The catalog’s real power: once services declare their dependsOn relationships, Backstage renders a dependency graph. You can see at a glance what depends on a database you’re planning to migrate, or trace the upstream impact of a service going down.

1
2
3
4
5
6
7
8
# In component's catalog-info.yaml
spec:
  dependsOn:
    - component:default/payments-service
    - resource:default/orders-postgres
    - resource:default/orders-redis
  consumesApis:
    - payments-api-v2

Navigate to the component in Backstage → “Dependencies” tab → interactive graph showing the full dependency tree.


Software Templates: Self-Service Scaffolding

Software Templates are the self-service engine of Backstage. A template defines a form that developers fill in, and a set of actions that run to scaffold code, create repositories, provision resources, and register the new entity in the catalog.

A Production-Ready 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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# templates/go-http-service/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: go-http-service
  title: Go HTTP Service
  description: |
    Production-ready Go HTTP service with:
    - GitHub Actions CI/CD
    - Kubernetes manifests (Helm chart)
    - Prometheus metrics and Grafana dashboard
    - Structured logging (zerolog)
    - Health check endpoints
    - OpenAPI spec
  tags:
    - go
    - http
    - recommended
  annotations:
    backstage.io/techdocs-ref: dir:.

spec:
  owner: platform-team
  type: service

  parameters:
    # Step 1: Service information
    - title: Service Information
      required:
        - name
        - description
        - owner
      properties:
        name:
          title: Service Name
          type: string
          description: Lowercase, hyphen-separated (e.g., order-processor)
          pattern: '^[a-z][a-z0-9-]{2,50}[a-z0-9]$'
          ui:autofocus: true
        description:
          title: Description
          type: string
          description: What does this service do? (shown in catalog)
        owner:
          title: Owner
          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 service belong to?
          ui:field: EntityPicker
          ui:options:
            catalogFilter:
              kind: System

    # Step 2: Infrastructure options
    - title: Infrastructure
      properties:
        includeDatabase:
          title: Include PostgreSQL database?
          type: boolean
          default: false
        includeRedis:
          title: Include Redis cache?
          type: boolean
          default: false
        includeQueue:
          title: Include message queue (NATS)?
          type: boolean
          default: false
        repoVisibility:
          title: Repository Visibility
          type: string
          default: private
          enum:
            - private
            - internal
            - public

    # Step 3: Deployment configuration
    - title: Deployment
      properties:
        productionApproval:
          title: Require manual approval for production deployments?
          type: boolean
          default: true
        minReplicas:
          title: Minimum Replicas
          type: integer
          default: 2
          minimum: 1
          maximum: 10
        cpuRequest:
          title: CPU Request (millicores)
          type: integer
          default: 100
        memoryRequest:
          title: Memory Request (Mi)
          type: integer
          default: 128

  steps:
    # 1. Fetch and render the skeleton template
    - id: fetch-skeleton
      name: Generate Code
      action: fetch:template
      input:
        url: ./skeleton
        copyWithoutRender:
          - .github/workflows/*.yml   # don't template YAML actions syntax
        values:
          name: ${{ parameters.name }}
          description: ${{ parameters.description }}
          owner: ${{ parameters.owner }}
          system: ${{ parameters.system }}
          includeDatabase: ${{ parameters.includeDatabase }}
          includeRedis: ${{ parameters.includeRedis }}
          cpuRequest: ${{ parameters.cpuRequest }}
          memoryRequest: ${{ parameters.memoryRequest }}
          minReplicas: ${{ parameters.minReplicas }}
          productionApproval: ${{ parameters.productionApproval }}
          year: ${{ '' | now('YYYY') }}

    # 2. Create the GitHub repository
    - id: create-repo
      name: Create GitHub Repository
      action: github:repo:create
      input:
        repoUrl: github.com?owner=lunarops&repo=${{ parameters.name }}
        description: ${{ parameters.description }}
        defaultBranch: main
        repoVisibility: ${{ parameters.repoVisibility }}
        collaborators:
          - team: ${{ parameters.owner }}
            access: admin

    # 3. Push the generated code
    - id: push-code
      name: Push Code to GitHub
      action: publish:github
      input:
        repoUrl: github.com?owner=lunarops&repo=${{ parameters.name }}
        defaultBranch: main
        commitMessage: "feat: initial service scaffold from platform template"

    # 4. Create branch protection rules
    - id: branch-protection
      name: Configure Branch Protection
      action: github:repo:push
      input:
        repoUrl: github.com?owner=lunarops&repo=${{ parameters.name }}
        branchProtection:
          - pattern: main
            requirePullRequestReviews: true
            requiredApprovingReviewCount: 1
            requireStatusChecks: true
            statusChecks: ['ci', 'security-scan']

    # 5. Provision Vault secrets path
    - id: provision-vault
      name: Provision Vault Secrets Path
      action: http:backstage:request
      input:
        method: POST
        path: /api/proxy/vault/v1/secret/data/${{ parameters.name }}/config
        body:
          data:
            placeholder: "Replace this with real secrets"

    # 6. Register ArgoCD application
    - id: create-argocd-app
      name: Register with ArgoCD
      action: argocd:create-resources
      input:
        appName: ${{ parameters.name }}-staging
        argoInstance: lunarops-production
        namespace: ${{ parameters.name }}-staging
        repoUrl: https://github.com/lunarops/${{ parameters.name }}
        path: deploy/helm

    # 7. Register in the Backstage catalog
    - id: register-catalog
      name: Register in Catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps['push-code'].output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml

  # Show useful links when the template completes
  output:
    links:
      - title: Repository
        url: ${{ steps['push-code'].output.remoteUrl }}
        icon: github
      - title: Open in catalog
        icon: catalog
        entityRef: ${{ steps['register-catalog'].output.entityRef }}
      - title: ArgoCD
        url: https://argocd.internal/applications/${{ parameters.name }}-staging
        icon: dashboard

    text:
      - title: Next Steps
        content: |
          Your service **${{ parameters.name }}** has been created! Here's what to do next:

          1. Clone the repo: `git clone git@github.com:lunarops/${{ parameters.name }}.git`
          2. Add real secrets to Vault: `vault kv put secret/${{ parameters.name }}/config key=value`
          3. Push your first commit — CI will run automatically
          4. Your service will auto-deploy to staging on merge to `main`

The Template Skeleton

The skeleton/ directory contains the files to render using Nunjucks templating:

skeleton/
├── catalog-info.yaml
├── README.md
├── go.mod
├── go.sum
├── main.go
├── internal/
│   ├── server/
│   │   └── server.go
│   └── health/
│       └── health.go
├── deploy/
│   └── helm/
│       ├── Chart.yaml
│       ├── values.yaml
│       └── templates/
│           ├── deployment.yaml
│           ├── service.yaml
│           └── ingress.yaml
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── deploy.yml
└── docs/
    ├── index.md
    └── mkdocs.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 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: lunarops/${{ values.name }}
    backstage.io/techdocs-ref: dir:.
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
// skeleton/main.go
package main

import (
    "log"
    "net/http"
    "os"

    "github.com/lunarops/${{ values.name }}/internal/server"
)

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    srv := server.New()
    log.Printf("Starting ${{ values.name }} on :%s", port)
    if err := http.ListenAndServe(":"+port, srv); err != nil {
        log.Fatal(err)
    }
}

TechDocs: Documentation as Code

TechDocs transforms Markdown files in your repo into a beautiful documentation site hosted inside Backstage. Documentation lives next to the code, gets reviewed in PRs, and never goes stale because it’s coupled to the service it documents.

Setting Up TechDocs for a Service

Add a docs/ directory and mkdocs.yml to any repository:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# mkdocs.yml (at the repo root)
site_name: My API Service
site_description: Order management REST API
docs_dir: docs/
nav:
  - Home: index.md
  - Architecture:
    - Overview: architecture/overview.md
    - Data Model: architecture/data-model.md
    - API: architecture/api.md
  - Operations:
    - Deployment: operations/deployment.md
    - Runbook: operations/runbook.md
    - Alerting: operations/alerting.md
  - Development:
    - Getting Started: development/getting-started.md
    - Local Setup: development/local-setup.md
    - Testing: development/testing.md

plugins:
  - techdocs-core   # required Backstage plugin
 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
<!-- docs/index.md -->
# My API Service

The Order Management API handles all order lifecycle operations — creation,
payment processing, fulfillment, and cancellation.

## Quick Links

| Resource | Link |
|----------|------|
| GitHub | [lunarops/my-api](https://github.com/lunarops/my-api) |
| Production | [my-api.lunarops.io](https://my-api.lunarops.io) |
| Dashboard | [Grafana](https://grafana.internal/d/my-api) |
| Alerts | [PagerDuty](https://lunarops.pagerduty.com/services/P1234567) |

## Ownership

**Team:** [Orders Team](/catalog/groups/default/orders-team)
**On-call:** Rotating — see PagerDuty schedule
**SLO:** 99.5% availability, P99 < 500ms

## Architecture

```mermaid
graph LR
    Client --> API[my-api]
    API --> DB[(PostgreSQL)]
    API --> Cache[(Redis)]
    API --> Payments[payments-service]
    API --> Notify[notification-service]

```markdown
<!-- docs/operations/runbook.md -->
# Runbook: My API Service

## High Error Rate

**Alert:** `MyApiHighErrorRate`

### Step 1: Check pod health
```bash
kubectl get pods -n my-api
kubectl describe pod <failing-pod> -n my-api

Step 2: Check recent deployments

1
kubectl rollout history deployment/my-api -n my-api

If recent deployment: kubectl rollout undo deployment/my-api -n my-api

Step 3: Check database connectivity

1
2
kubectl exec -it deployment/my-api -n my-api -- \
  /bin/sh -c "pg_isready -h $DB_HOST -p $DB_PORT"

Contacts

  • Primary on-call: PagerDuty escalation policy “orders-team”
  • Escalation: #orders-team-incidents in Slack

```yaml
# catalog-info.yaml — point to your docs
metadata:
  annotations:
    backstage.io/techdocs-ref: dir:.   # mkdocs.yml is at the repo root

TechDocs renders this in Backstage under the component’s “Docs” tab — styled, searchable, and always reflecting the current state of the main branch.

Building TechDocs in CI

For production deployments, build TechDocs in CI and publish to object storage (GCS, S3, Azure Blob):

 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
# .github/workflows/techdocs.yml
name: Publish TechDocs

on:
  push:
    branches: [main]
    paths:
      - 'docs/**'
      - 'mkdocs.yml'

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

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

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

      - name: Generate TechDocs
        run: techdocs-cli generate --no-docker --source-dir . --output-dir ./site

      - name: Publish TechDocs to GCS
        run: |
          techdocs-cli publish \
            --publisher-type googleGcs \
            --storage-name lunarops-techdocs \
            --entity default/Component/my-api \
            --directory ./site
        env:
          GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GCS_SA_KEY }}

Plugins: Extending Backstage

Plugins are what make Backstage genuinely useful beyond the catalog. They surface external system data directly on entity pages — no tab-switching required.

Installing Community Plugins

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Kubernetes plugin — show pod status on component pages
yarn --cwd packages/app add @backstage/plugin-kubernetes
yarn --cwd packages/backend add @backstage/plugin-kubernetes-backend

# ArgoCD plugin — show deployment status
yarn --cwd packages/app add @roadiehq/backstage-plugin-argo-cd

# PagerDuty plugin — show on-call and incidents
yarn --cwd packages/app add @pagerduty/backstage-plugin

# GitHub Actions plugin — show CI/CD runs
yarn --cwd packages/app add @backstage/plugin-github-actions

# SonarQube plugin — show code quality metrics
yarn --cwd packages/app add @backstage/plugin-sonarqube

# Grafana plugin — embed dashboards
yarn --cwd packages/app add @k-phoen/backstage-plugin-grafana
 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
// packages/app/src/components/catalog/EntityPage.tsx
// Add plugin cards to the service entity page

import { EntityKubernetesContent } from '@backstage/plugin-kubernetes';
import { EntityArgoCDOverviewCard } from '@roadiehq/backstage-plugin-argo-cd';
import { EntityPagerDutyCard } from '@pagerduty/backstage-plugin';
import { EntityGithubActionsContent } from '@backstage/plugin-github-actions';
import { EntitySonarQubeCard } from '@backstage/plugin-sonarqube';

const serviceEntityPage = (
  <EntityLayout>
    <EntityLayout.Route path="/" title="Overview">
      <Grid container spacing={3}>
        <Grid item md={6}>
          <EntityAboutCard variant="gridItem" />
        </Grid>
        <Grid item md={6}>
          <EntityLinksCard />
        </Grid>
        {/* Show ArgoCD deployment status */}
        <Grid item md={6}>
          <EntityArgoCDOverviewCard />
        </Grid>
        {/* Show PagerDuty on-call and recent incidents */}
        <Grid item md={6}>
          <EntityPagerDutyCard />
        </Grid>
        {/* Show SonarQube code quality */}
        <Grid item md={6}>
          <EntitySonarQubeCard />
        </Grid>
      </Grid>
    </EntityLayout.Route>

    {/* Full Kubernetes tab */}
    <EntityLayout.Route path="/kubernetes" title="Kubernetes">
      <EntityKubernetesContent refreshIntervalMs={30000} />
    </EntityLayout.Route>

    {/* GitHub Actions CI/CD tab */}
    <EntityLayout.Route path="/ci-cd" title="CI/CD">
      <EntityGithubActionsContent />
    </EntityLayout.Route>

    {/* TechDocs tab */}
    <EntityLayout.Route path="/docs" title="Docs">
      <EntityTechdocsContent />
    </EntityLayout.Route>

    {/* API definition tab */}
    <EntityLayout.Route path="/api" title="API">
      <EntityApiDefinitionCard />
    </EntityLayout.Route>

    {/* Dependency graph */}
    <EntityLayout.Route path="/dependencies" title="Dependencies">
      <EntityDependencyOfComponentsCard />
      <EntityDependsOnComponentsCard />
      <EntityDependsOnResourcesCard />
    </EntityLayout.Route>
  </EntityLayout>
);

Writing a Custom Plugin

When no community plugin exists, write your own. Backstage’s plugin system is well-designed for this:

1
2
3
4
5
# Scaffold a new plugin
yarn backstage-cli new --select plugin

# Prompts for plugin ID (e.g., "deployment-tracker")
# Creates packages/app/src/plugins/deployment-tracker/
 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
// A simple custom plugin that shows deployment history from your API
// packages/app/src/plugins/deployment-tracker/src/components/DeploymentCard.tsx

import React, { useEffect, useState } from 'react';
import { useApi, discoveryApiRef } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { InfoCard, Table } from '@backstage/core-components';

interface Deployment {
  version: string;
  environment: string;
  deployedAt: string;
  deployedBy: string;
  status: 'success' | 'failed' | 'in-progress';
}

export const DeploymentHistoryCard = () => {
  const { entity } = useEntity();
  const discoveryApi = useApi(discoveryApiRef);
  const [deployments, setDeployments] = useState<Deployment[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchDeployments = async () => {
      const baseUrl = await discoveryApi.getBaseUrl('deployment-tracker');
      const serviceName = entity.metadata.name;
      const response = await fetch(
        `${baseUrl}/deployments/${serviceName}?limit=10`
      );
      const data = await response.json();
      setDeployments(data.deployments);
      setLoading(false);
    };
    fetchDeployments();
  }, [entity.metadata.name, discoveryApi]);

  const columns = [
    { title: 'Version', field: 'version' },
    { title: 'Environment', field: 'environment' },
    { title: 'Deployed By', field: 'deployedBy' },
    { title: 'Time', field: 'deployedAt' },
    {
      title: 'Status',
      field: 'status',
      render: (row: Deployment) => (
        <span style={{ color: row.status === 'success' ? 'green' : 'red' }}>
          {row.status}
        </span>
      ),
    },
  ];

  return (
    <InfoCard title="Deployment History">
      <Table
        isLoading={loading}
        data={deployments}
        columns={columns}
        options={{ paging: false, search: false }}
      />
    </InfoCard>
  );
};

Deploying Backstage to Kubernetes

  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
# k8s/backstage-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backstage
  namespace: backstage
spec:
  replicas: 2
  selector:
    matchLabels:
      app: backstage
  template:
    metadata:
      labels:
        app: backstage
    spec:
      serviceAccountName: backstage
      containers:
        - name: backstage
          image: ghcr.io/lunarops/backstage:latest
          ports:
            - containerPort: 7007
          env:
            - name: POSTGRES_HOST
              valueFrom:
                secretKeyRef:
                  name: backstage-db
                  key: host
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: backstage-db
                  key: password
            - name: GITHUB_TOKEN
              valueFrom:
                secretKeyRef:
                  name: backstage-secrets
                  key: github-token
            - name: AUTH_GITHUB_CLIENT_ID
              valueFrom:
                secretKeyRef:
                  name: backstage-secrets
                  key: github-client-id
            - name: AUTH_GITHUB_CLIENT_SECRET
              valueFrom:
                secretKeyRef:
                  name: backstage-secrets
                  key: github-client-secret
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              cpu: 1000m
              memory: 1Gi
          readinessProbe:
            httpGet:
              path: /healthcheck
              port: 7007
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /healthcheck
              port: 7007
            initialDelaySeconds: 60
            periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
  name: backstage
  namespace: backstage
spec:
  selector:
    app: backstage
  ports:
    - port: 80
      targetPort: 7007
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: backstage
  namespace: backstage
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  rules:
    - host: backstage.lunarops.io
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: backstage
                port:
                  number: 80
  tls:
    - hosts:
        - backstage.lunarops.io
      secretName: backstage-tls

Build the Docker image as part of CI:

 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
# Dockerfile
FROM node:18-bookworm-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y python3 g++ make && rm -rf /var/lib/apt/lists/*

FROM base AS deps
COPY package.json yarn.lock ./
COPY packages/app/package.json packages/app/
COPY packages/backend/package.json packages/backend/
RUN yarn install --frozen-lockfile --network-timeout 600000

FROM deps AS build
COPY . .
RUN yarn tsc
RUN yarn --cwd packages/backend build
RUN yarn --cwd packages/app build

FROM node:18-bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y libsqlite3-dev python3 && rm -rf /var/lib/apt/lists/*

COPY --from=build /app/packages/backend/dist ./packages/backend/dist
COPY --from=build /app/packages/backend/node_modules ./packages/backend/node_modules
COPY --from=build /app/packages/app/dist ./packages/app/dist
COPY app-config.yaml ./

USER node
EXPOSE 7007
CMD ["node", "packages/backend/dist/index.js"]

Making Backstage Succeed: Common Pitfalls

The empty catalog problem. A catalog with 3 entries is useless. Run a “catalog migration sprint” where every team adds catalog-info.yaml to their repos in one week. Make it a requirement for all new services from day one.

Stale data. Backstage is only as useful as the data it shows. Auto-discovery via the GitHub provider solves this for entity discovery. For plugin data (PagerDuty, Grafana, Kubernetes), the integrations pull live data — but only if annotations are correct. Audit annotations quarterly.

Plugin overload. It’s tempting to install every plugin. Start with five that address real pain: Kubernetes status, CI/CD history, on-call (PagerDuty), code quality (SonarQube), and TechDocs. Add more when developers ask for them.

Treating it as a build-once project. Backstage requires ongoing maintenance — plugin updates, configuration changes, new template additions. Assign someone to own it as a product, not as infrastructure. Schedule quarterly “Backstage days” where the platform team improves templates and documentation based on developer feedback.

Missing authentication. The demo setup uses no auth. Production requires GitHub OAuth or OIDC. Users seeing each other’s data or being unable to identify component owners breaks the trust model.


Backstage works when it becomes the place developers actually go — not because they’re required to, but because it’s genuinely useful. A service page with live Kubernetes pod status, recent deploys, on-call schedule, code coverage, and documentation all in one tab is more useful than seven separate tools. The catalog makes onboarding a new engineer faster. Templates make creating a new service a form rather than a multi-day process. TechDocs makes documentation reviewable and maintainable.

The investment is real — Backstage takes time to set up and maintain. But the return is an organization where “where does this live?” has an answer, and “how do I create a new service?” is a 10-minute task rather than a week-long project.

Comments