DevOpsil

Backstage Developer Portal Setup for Platform Teams

Zara BlackwoodZara Blackwood8 min read

Your Platform Needs a Front Door

You've built the platform — Terraform modules, Crossplane compositions, CI/CD pipelines, monitoring stacks. But if developers can't find them, they don't exist.

Backstage is that front door. Built by Spotify and open-sourced through the CNCF, it gives your platform a single pane of glass: software catalog, scaffolding templates, and documentation in one place.

Here's how I set it up for a platform team of 8 serving 120+ developers.

App Configuration

# app-config.yaml
app:
  title: DevOpsil Platform Portal
  baseUrl: http://localhost:3000

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

catalog:
  rules:
    - allow: [Component, System, API, Resource, Template, Group, User]
  locations:
    - type: url
      target: https://github.com/your-org/backstage-catalog/blob/main/all.yaml
      rules:
        - allow: [Component, System, API, Resource, Template]

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

techdocs:
  builder: external
  publisher:
    type: awsS3
    awsS3:
      bucketName: devopsil-techdocs
      region: us-east-1

Use PostgreSQL from day one. SQLite won't survive a pod restart. The database is the backbone of the catalog.

Production Deployment

# k8s/backstage/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backstage
  namespace: backstage
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: backstage
  template:
    metadata:
      labels:
        app.kubernetes.io/name: backstage
    spec:
      serviceAccountName: backstage
      containers:
        - name: backstage
          image: your-registry/backstage:latest
          ports:
            - containerPort: 7007
          envFrom:
            - secretRef:
                name: backstage-secrets
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 1Gi
          readinessProbe:
            httpGet:
              path: /healthcheck
              port: 7007
            initialDelaySeconds: 30
          livenessProbe:
            httpGet:
              path: /healthcheck
              port: 7007
            initialDelaySeconds: 60
# k8s/backstage/external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: backstage-secrets
  namespace: backstage
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: backstage-secrets
  data:
    - secretKey: POSTGRES_PASSWORD
      remoteRef:
        key: backstage/database
        property: password
    - secretKey: GITHUB_TOKEN
      remoteRef:
        key: backstage/github
        property: token

Never put secrets in app-config.yaml. Use external-secrets-operator to pull them from your vault.

Software Catalog: The Source of Truth

Every service gets a catalog-info.yaml in its repo root:

# catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: orders-api
  description: "Order processing API"
  annotations:
    github.com/project-slug: your-org/orders-api
    backstage.io/techdocs-ref: dir:.
    argocd/app-name: orders-api
  tags: [python, fastapi]
  links:
    - url: https://grafana.internal/d/orders-api
      title: Grafana Dashboard
spec:
  type: service
  lifecycle: production
  owner: group:orders-team
  system: order-platform
  dependsOn:
    - resource:orders-db

The catalog is the single source of truth for ownership. When something breaks at 3 AM, Backstage tells you who owns it. No Slack spelunking.

Software Templates: Golden Paths

# templates/new-service/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: new-python-service
  title: Create a New Python Service
  description: "Scaffolds a production-ready Python service with CI/CD and monitoring"
spec:
  owner: group:platform-team
  type: service
  parameters:
    - title: Service Details
      required: [name, owner]
      properties:
        name:
          title: Service Name
          type: string
          pattern: "^[a-z][a-z0-9-]{2,30}$"
        owner:
          title: Owner Team
          type: string
          ui:field: OwnerPicker
          ui:options:
            catalogFilter:
              kind: Group
        database:
          title: Database
          type: string
          enum: ["none", "postgres", "mysql"]
          default: "none"

  steps:
    - id: fetch-template
      name: Fetch Template
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}

    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        allowedHosts: ["github.com"]
        repoUrl: github.com?owner=your-org&repo=${{ parameters.name }}
        defaultBranch: main
        repoVisibility: internal

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

  output:
    links:
      - title: Repository
        url: ${{ steps['publish'].output.remoteUrl }}

Developer fills a form, clicks "Create", and gets: a GitHub repo with best-practice structure, CI/CD pipeline, and catalog registration. Five minutes from idea to running service.

TechDocs: Documentation as Code

# In the service repo: mkdocs.yml
site_name: Orders API
nav:
  - Home: index.md
  - Architecture: architecture.md
  - Runbook: runbook.md
plugins:
  - techdocs-core
# CI step to publish TechDocs
- name: Publish TechDocs
  run: |
    npx @techdocs/cli generate --source-dir . --output-dir ./site
    npx @techdocs/cli publish \
      --publisher-type awsS3 \
      --storage-name devopsil-techdocs \
      --entity default/component/orders-api

Docs live next to code, get reviewed in PRs, and publish automatically. If docs are in Confluence, they're already out of date.

Kubernetes Plugin

# app-config.yaml addition
kubernetes:
  serviceLocatorMethod:
    type: multiTenant
  clusterLocatorMethods:
    - type: config
      clusters:
        - url: https://k8s-api.internal:6443
          name: production
          authProvider: serviceAccount
          serviceAccountToken: ${K8S_SA_TOKEN}

Developers see pod status, logs, and events directly in Backstage. No more "can someone with cluster access check my pods?" in Slack.

Authentication and Authorization

Backstage needs auth from day one. Without it, anyone with network access sees your entire software catalog.

GitHub OAuth

# app-config.yaml
auth:
  environment: production
  providers:
    github:
      production:
        clientId: ${GITHUB_OAUTH_CLIENT_ID}
        clientSecret: ${GITHUB_OAUTH_CLIENT_SECRET}
        signIn:
          resolvers:
            - resolver: usernameMatchingUserEntityName

RBAC with the Permission Framework

// packages/backend/src/plugins/permission.ts
import { createRouter } from '@backstage/plugin-permission-backend';
import { AuthorizeResult } from '@backstage/plugin-permission-common';

export default async function createPlugin(env) {
  return await createRouter({
    config: env.config,
    logger: env.logger,
    discovery: env.discovery,
    policy: {
      async handle(request) {
        // Platform team can do everything
        if (request.principal.userEntityRef?.includes('platform-team')) {
          return { result: AuthorizeResult.ALLOW };
        }
        // Others get read-only by default
        if (request.permission.name.startsWith('catalog.entity.read')) {
          return { result: AuthorizeResult.ALLOW };
        }
        return { result: AuthorizeResult.DENY };
      },
    },
  });
}

Start with coarse permissions and refine. Read access for everyone, write access for the platform team, template execution for team leads.

System Modeling: Beyond Components

Individual services are components. Related components form systems. Model the relationships.

# catalog/systems/order-platform.yaml
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
  name: order-platform
  description: "End-to-end order processing"
spec:
  owner: group:orders-team
  domain: commerce
---
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
  name: orders-api
  description: "REST API for order management"
  annotations:
    backstage.io/techdocs-ref: dir:.
spec:
  type: openapi
  lifecycle: production
  owner: group:orders-team
  system: order-platform
  definition:
    $text: ./openapi.yaml
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
  name: orders-db
  description: "PostgreSQL database for orders"
spec:
  type: database
  owner: group:orders-team
  system: order-platform

When someone opens the order-platform system in Backstage, they see every API, service, and database that composes it. During an incident, this map is invaluable.

Custom Plugins for Your Platform

Backstage's power is extensibility. Write plugins that surface platform-specific data.

// plugins/cost-dashboard/src/components/CostWidget.tsx
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { InfoCard } from '@backstage/core-components';

export const CostWidget = () => {
  const { entity } = useEntity();
  const serviceName = entity.metadata.name;

  // Fetch cost data from your internal API
  const { data, loading } = useCostData(serviceName);

  if (loading) return <Progress />;

  return (
    <InfoCard title="Monthly Cloud Cost">
      <Typography variant="h4">${data.monthlyCost}</Typography>
      <Typography color="textSecondary">
        {data.trend > 0 ? '+' : ''}{data.trend}% vs last month
      </Typography>
    </InfoCard>
  );
};

Register it on the entity page so every service shows its cloud cost. Same pattern works for deployment history, on-call schedules, SLO dashboards, and security scan results.

Scaling Backstage

Catalog Refresh Tuning

# app-config.yaml
catalog:
  providers:
    github:
      yourOrg:
        organization: your-org
        catalogPath: /catalog-info.yaml
        filters:
          branch: main
          repository: '.*'
        schedule:
          frequency: { minutes: 30 }  # Don't poll every minute
          timeout: { minutes: 3 }

For organizations with 500+ repos, reduce the catalog refresh frequency to 30 minutes. Real-time discovery isn't worth API rate limits and database load.

Search Configuration

# app-config.yaml
search:
  pg:
    highlightOptions:
      useHighlight: true
      maxWords: 35
      minWords: 15

PostgreSQL full-text search scales well to 10,000+ entities. For larger catalogs, consider Elasticsearch as the search backend.

Troubleshooting

Problem: Catalog entities not showing up. Fix: Check catalog import logs at /catalog-import. Most common causes: YAML syntax error in catalog-info.yaml, GitHub token missing repo scope, or the entity's kind isn't in the allowed list under catalog.rules.

Problem: Templates fail at the publish step. Fix: GitHub token needs repo and workflow scopes. Also verify the org allows API-based repo creation. Check the scaffolder logs at <backstage-url>/api/scaffolder/v2/tasks for detailed error messages.

Problem: Backstage slow on large catalogs (500+ entities). Fix: Enable search indexing, use PostgreSQL (not SQLite), and reduce catalog refresh frequency with the schedule option. Add database connection pooling with pg-pool.

Problem: TechDocs renders blank pages. Fix: Verify mkdocs.yml exists in the repo root and techdocs-core is listed as a plugin. Check that the S3 bucket has the correct CORS configuration if using external storage.

Problem: Entity relationships show "Not Found." Fix: Entity references must match exactly. If a component references group:orders-team as its owner, that group entity must exist in the catalog with kind: Group and metadata.name: orders-team.

Conclusion

Backstage is a product you build for your developers, not a tool you install and forget. Start with the catalog — get every service registered with an owner. Model systems and APIs to show relationships. Add templates for common service patterns. Layer in TechDocs once teams see value. Build custom plugins that surface the data developers actually need. The portal works when it's the first place developers go.

Measuring Adoption

Track these metrics to prove Backstage is delivering value:

  • Catalog completeness: percentage of services with a catalog-info.yaml. Target 95%+ within 6 months.
  • Template usage: number of services created through Backstage templates vs manually. If developers still create repos by hand, your templates need improvement.
  • TechDocs coverage: percentage of production services with documentation published. Track this weekly.
  • Portal DAU/WAU: daily and weekly active users. If the number plateaus, investigate why teams aren't returning.

Make it indispensable.

Share:
Zara Blackwood
Zara Blackwood

Platform Engineer

Terraform enthusiast, platform builder, DRY advocate. I believe infrastructure should be versioned, reviewed, and deployed like any other code. GitOps or bust.

Related Articles