Backstage Developer Portal Setup for Platform Teams
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.
Related Articles
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
Crossplane: Managing Cloud Infrastructure from Kubernetes
How to use Crossplane to provision and manage cloud infrastructure using Kubernetes-native APIs — one control plane to rule them all.
Pulumi vs Terraform: An Honest Comparison from the Trenches
A real-world comparison of Pulumi and Terraform — where each shines, where each hurts, and how to pick the right one for your team.
Terraform from Zero to Production: Project Structure, Modules, State, and CI/CD
Build production-grade Terraform infrastructure — project structure, module design, state management, testing, and CI/CD pipeline integration.