Skip to content

Backstage - Street-Level Ops

Quick Diagnosis Commands

# Check Backstage app status (Kubernetes deployment)
kubectl get pods -n backstage
kubectl logs -n backstage deployment/backstage --tail=50

# Check database connectivity (Backstage requires PostgreSQL in prod)
kubectl exec -n backstage deployment/backstage -- \
  node -e "require('pg').Pool({connectionString: process.env.POSTGRES_URL}).query('SELECT 1')"

# Validate a catalog-info.yaml file locally
npx @backstage/cli catalog:validate catalog-info.yaml

# Run Backstage locally for development
yarn dev

# Build production bundle
yarn build

# Check Backstage CLI version
npx @backstage/cli --version

Gotcha: Backstage auto-discovers entities on a polling interval (default: every 100-200 seconds). After registering a new location, the entity may take several minutes to appear in the catalog. Hitting refresh in the UI does nothing -- use the /api/catalog/refresh endpoint to force an immediate ingestion cycle.

# List installed plugins
cat package.json | jq '.dependencies | keys[] | select(startswith("@backstage"))'
# Backstage CLI commands
npx @backstage/cli new              # scaffold new app or plugin
npx @backstage/cli versions:check   # check for outdated Backstage packages
npx @backstage/cli versions:bump    # bump all Backstage packages to latest

# App config validation
node packages/backend/dist/index.js --config app-config.yaml --dry-run

# Check entity ingestion status via API
curl -s http://localhost:7007/api/catalog/entities | jq 'length'
curl -s "http://localhost:7007/api/catalog/entities?filter=kind=component" | jq '.[].metadata.name'

# Force catalog refresh (admin API)
curl -X POST http://localhost:7007/api/catalog/refresh \
  -H 'Content-Type: application/json' \
  -d '{"entityRef": "component:default/my-service"}'
# Check catalog entity validation errors
curl -s "http://localhost:7007/api/catalog/locations" | jq '.[] | select(.data.error != null)'

# Get specific entity details
curl -s "http://localhost:7007/api/catalog/entities/by-name/component/default/my-service" | jq .

# List all locations (sources of entities)
curl -s "http://localhost:7007/api/catalog/locations" | jq '.[] | {type: .data.type, target: .data.target}'

# Check TechDocs build status
curl -s "http://localhost:7007/api/techdocs/metadata/docs/default/component/my-service" | jq .

Common Scenarios

Scenario 1: Entity Not Appearing in Catalog

A team added a catalog-info.yaml but their service doesn't show up in Backstage.

# Step 1: Check if the location is registered
curl -s "http://localhost:7007/api/catalog/locations" | \
  jq '.[] | select(.data.target | contains("my-repo"))'

# Step 2: If not registered, add the location
curl -X POST http://localhost:7007/api/catalog/locations \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "url",
    "target": "https://github.com/myorg/my-service/blob/main/catalog-info.yaml"
  }'

# Step 3: Check for validation errors in the entity
curl -s "http://localhost:7007/api/catalog/entities/by-name/component/default/my-service" \
  | jq '.metadata.annotations'

# Step 4: Validate the YAML format manually
cat catalog-info.yaml
# Required fields:
# apiVersion: backstage.io/v1alpha1
# kind: Component
# metadata:
#   name: my-service
# spec:
#   type: service
#   lifecycle: production
#   owner: team-name

# Step 5: Force refresh
curl -X POST http://localhost:7007/api/catalog/refresh \
  -H 'Content-Type: application/json' \
  -d '{"entityRef": "component:default/my-service"}'

Scenario 2: TechDocs Not Rendering

Service has mkdocs.yml and docs folder but TechDocs shows an error.

# Check TechDocs plugin configuration in app-config.yaml
grep -A 10 "techdocs:" app-config.yaml

# For local builds (development)
# app-config.yaml should have:
# techdocs:
#   builder: local
#   generator:
#     runIn: local
#   publisher:
#     type: local

# For production (S3/GCS storage)
# techdocs:
#   builder: external   # built in CI, not by Backstage
#   publisher:
#     type: awsS3
#     awsS3:
#       bucketName: my-techdocs-bucket

# Build docs locally to test
cd path/to/service
npx @techdocs/cli generate --no-docker
npx @techdocs/cli serve  # preview at http://localhost:3000

# Check mkdocs.yml has required Backstage config
cat mkdocs.yml
# site_name must exist
# plugins must include techdocs-core

Scenario 3: Auth Provider Not Working

Users get authentication errors when logging in via GitHub/Google OAuth.

# Check auth configuration in app-config.yaml
grep -A 20 "auth:" app-config.yaml

# Verify environment variables are set
kubectl exec -n backstage deployment/backstage -- env | grep -i "AUTH_\|CLIENT_ID\|CLIENT_SECRET"

# Common GitHub OAuth config:
# auth:
#   environment: production
#   providers:
#     github:
#       production:
#         clientId: ${AUTH_GITHUB_CLIENT_ID}
#         clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}

# Check callback URL matches GitHub OAuth App settings
# Callback URL must be: https://backstage.example.com/api/auth/github/handler/frame

# Check Backstage logs for auth errors
kubectl logs -n backstage deployment/backstage --tail=100 | grep -i "auth\|oauth\|token"

Scenario 4: Database Migration Failures on Upgrade

Backstage fails to start after version upgrade due to schema migration issues.

# Check Backstage startup logs
kubectl logs -n backstage deployment/backstage --tail=200 | grep -i "migration\|database\|error"

# Check current database schema version
kubectl exec -n backstage deployment/backstage -- \
  node -e "
const knex = require('knex')({ client: 'pg', connection: process.env.POSTGRES_URL });
knex('knex_migrations').select().then(r => { console.log(r); knex.destroy(); });
"

# Backstage runs migrations on startup automatically
# If stuck: check for long-running transactions or advisory locks
# Connect to PostgreSQL directly:
psql $POSTGRES_URL -c "SELECT pid, query, state FROM pg_stat_activity WHERE state != 'idle';"

# Kill blocking connections if needed
psql $POSTGRES_URL -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state != 'idle' AND pid != pg_backend_pid();"

# Rollback to previous Backstage version in emergency
kubectl set image deployment/backstage backstage=backstage:previous-version -n backstage

Key Patterns

catalog-info.yaml for Common Entity Types

# Component (a deployable service, library, or website)
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: my-service
  description: "Order processing service"
  annotations:
    github.com/project-slug: myorg/my-service
    backstage.io/techdocs-ref: dir:.
    pagerduty.com/service-id: PXXXXXX
  tags:
    - java
    - orders
spec:
  type: service
  lifecycle: production
  owner: group:team-platform
  system: order-management
  dependsOn:
    - component:default/order-database
  providesApis:
    - order-api

---
# API definition
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
  name: order-api
spec:
  type: openapi
  lifecycle: production
  owner: group:team-platform
  definition:
    $text: ./openapi.yaml

---
# Resource (database, S3 bucket, etc.)
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
  name: order-database
spec:
  type: database
  owner: group:team-platform
  system: order-management

app-config.yaml Structure

app:
  title: My Company Portal
  baseUrl: https://backstage.example.com

backend:
  baseUrl: https://backstage.example.com
  listen:
    port: 7007
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: 5432
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
      database: backstage

catalog:
  rules:
    - allow: [Component, System, API, Resource, Location, Group, User]
  locations:
    # Auto-discover catalog-info.yaml files in GitHub org
    - type: github-discovery
      target: https://github.com/myorg

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

Checking Entity Relationships

# Get all dependencies of a component
curl -s "http://localhost:7007/api/catalog/entities/by-name/component/default/my-service" | \
  jq '.relations[] | {type: .type, target: .target.name}'

# Find all components owned by a team
curl -s "http://localhost:7007/api/catalog/entities?filter=kind=component,spec.owner=group:default/team-platform" | \
  jq '.[].metadata.name'

# List all APIs in production lifecycle
curl -s "http://localhost:7007/api/catalog/entities?filter=kind=api,spec.lifecycle=production" | \
  jq '.[].metadata.name'

Software Templates (Scaffolder)

# template.yaml — creates a new service from a template
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: service-template
  title: New Service Template
spec:
  owner: platform-team
  type: service
  parameters:
    - title: Service Details
      required: [name, owner]
      properties:
        name:
          type: string
          title: Service Name
        owner:
          type: string
          title: Owner Team
  steps:
    - id: fetch-template
      name: Fetch Template
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
    - id: create-repo
      name: Create Repository
      action: publish:github
      input:
        repoUrl: github.com?repo=${{ parameters.name }}&owner=myorg
    - id: register
      name: Register in Catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps.create-repo.output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml

Default trap: The Backstage scaffolder publish:github action creates repos with the default branch name from your GitHub org settings (usually main). If your template references blob/master/catalog-info.yaml in the registration step, the catalog location silently fails to resolve. Always use blob/main/ or parametrize the branch name.