Introduction
As organizations scale, developers face an ever-growing complexity of tools, workflows, and infrastructure. Each team reinvents the wheelโcreating their own deployment pipelines, documentation systems, and operational runbooks. Platform engineering emerges as the discipline to solve this chaos, and Backstage is the leading open-source framework for building internal developer platforms.
In 2026, Backstage has evolved from Spotify’s internal tool to an enterprise-grade platform used by thousands of organizations. This guide covers building, customizing, and operating Backstage-based internal developer platforms.
Understanding Platform Engineering
What is an Internal Developer Platform?
An Internal Developer Platform (IDP) is a layer of abstraction that:
- Standardizes how developers interact with infrastructure
- Self-service enables developers to provision resources without tickets
- Provides guardrails ensuring best practices are followed
- Reduces cognitive load by hiding complexity
Backstage Overview
Backstage provides:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Backstage Portal โ
โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโค
โ Software โ Service โ Developer โ
โ Catalog โ Templates โ Docs โ
โโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโค
โ Plugins โ Search โ Extensions โ
โโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโ
Getting Started
Installation
# Create Backstage app
npx @backstage/create-app@latest my-backstage
# Navigate to directory
cd my-backstage
# Start development server
cd my-backstage && yarn dev
Project Structure
my-backstage/
โโโ packages/
โ โโโ app/ # Frontend React app
โโโ plugins/ # Your custom plugins
โโโ catalog-info.yaml # Root catalog config
โโโ app-config.yaml # App configuration
โโโ yarn.lock
Basic Configuration
# app-config.yaml
app:
title: My Company Developer Portal
baseUrl: http://localhost:3000
organization:
name: My Company
backend:
baseUrl: http://localhost:7007
cors:
origin: http://localhost:3000
proxy:
'/pagerduty/api':
target: https://api.pagerduty.com
headers:
Authorization: Token token=${PAGERDUTY_TOKEN}
catalog:
import:
entityFilename: catalog-info.yaml
locations:
- type: url
target: https://github.com/org/repo/blob/main/catalog-info.yaml
search:
providers:
techdocs:
builder: local
The Software Catalog
Registering Components
# catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-service
description: Payment processing service
tags:
- nodejs
- payment
- stripe
links:
- url: https://payment.service.internal
title: Service Dashboard
- url: https://grafana.internal/d/payment
title: Grafana
spec:
type: service
lifecycle: production
owner: platform-team
system: payments
providesApis:
- payment-api
dependsOn:
- resource:postgres-db
- component:customer-service
Entity Types
# API definition
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: payment-api
description: Payment service REST API
spec:
type: openapi
lifecycle: production
owner: platform-team
system: payments
definition:
$openapi: ./payment-api.yaml
# Resource
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: postgres-db
spec:
type: database
lifecycle: production
owner: platform-team
system: payments
# System
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: payments
description: Payment processing system
spec:
owner: platform-team
domain: commerce
components:
- component:payment-service
- component:payment-worker
Custom Entity Providers
// plugins/custom-provider/src/provider.ts
import { EntityProvider, EntityProviderConnection } from '@backstage/plugin-catalog-backend';
import { Logger } from 'winston';
export class CustomEntityProvider implements EntityProvider {
constructor(
private logger: Logger,
private config: CustomConfig,
) {}
getProviderName(): string {
return 'custom-entity-provider';
}
async connect(connection: EntityProviderConnection): Promise<void> {
// Start polling or streaming for entities
await this.initialSync(connection);
// Set up ongoing sync
setInterval(() => this.sync(connection), 60000);
}
private async initialSync(connection: EntityProviderConnection): Promise<void> {
const entities = await this.fetchEntities();
await connection.applyMutation({
type: 'full',
entities: entities.map(entity => ({
entity,
relations: [],
})),
});
}
private async fetchEntities(): Promise<any[]> {
// Fetch from your source (database, API, etc.)
return [];
}
}
Building Plugins
Creating a Plugin
# Create a new plugin
cd my-backstage
yarn new --plugin my-plugin
Plugin Structure
// plugins/my-plugin/src/plugin.ts
import { createPlugin } from '@backstage/core-plugin-api';
import { myPluginRouteRef } from './routes';
export const myPlugin = createPlugin({
id: 'my-plugin',
routes: {
root: myPluginRouteRef,
},
});
// plugins/my-plugin/src/components/ExampleComponent.tsx
import React from 'react';
import { useApi, identityApiRef } from '@backstage/core-plugin-api';
export const ExampleComponent = () => {
const identityApi = useApi(identityApiRef);
return (
<div>
<h1>Hello, {(await identityApi.getUser()).displayName}</h1>
</div>
);
};
React Components
import React, { useState, useEffect } from 'react';
import {
Table,
TableColumn,
StatusOK,
StatusWarning,
StatusError
} from '@backstage/core-components';
interface Deployment {
id: string;
service: string;
environment: string;
status: 'success' | 'failed' | 'pending';
version: string;
timestamp: string;
}
export const DeploymentsTable = ({ deployments }: { deployments: Deployment[] }) => {
const columns: TableColumn<Deployment>[] = [
{ title: 'Service', field: 'service' },
{ title: 'Environment', field: 'environment' },
{
title: 'Status',
field: 'status',
render: (row) => {
const statusMap = {
success: <StatusOK />,
failed: <StatusError />,
pending: <StatusWarning />,
};
return statusMap[row.status];
}
},
{ title: 'Version', field: 'version' },
{ title: 'Deployed', field: 'timestamp' },
];
return (
<Table
title="Recent Deployments"
data={deployments}
columns={columns}
options={{ paging: true, pageSize: 10 }}
/>
);
};
Backend Extensions
// plugins/my-plugin-backend/src/service.ts
import { createServiceBuilder } from '@backstage/backend-common';
import { Router } from 'express';
import express from 'express';
export interface PluginEnvironment {
logger: Logger;
config: Config;
}
export async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const service = createServiceBuilder(module)
.addRoutes({
'/health': (_, res) => res.json({ status: 'ok' }),
'/api/deployments': async (_, res) => {
const deployments = await getDeployments(env);
res.json(deployments);
},
});
return service.build().router;
}
Software Templates
Creating Templates
# templates/podcast-api/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: podcast-api-template
title: New Podcast API Service
description: Scaffolds a new podcast API service
tags:
- api
- nodejs
- express
spec:
owner: platform-team
type: service
parameters:
- title: Service Information
properties:
name:
title: Service Name
type: string
pattern: '^[a-z0-9-]+$'
description:
title: Description
type: string
owner:
title: Owner
type: string
ui:field: OwnerPicker
- title: Infrastructure
properties:
database:
title: Add Database
type: boolean
default: true
cache:
title: Add Redis Cache
type: boolean
default: false
steps:
- id: fetch-base
name: Fetch Base
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
- id: fetch-infra
name: Fetch Infrastructure
action: fetch:plain
input:
url: https://github.com/org/infra-templates
targetPath: ./infra
values:
database: ${{ parameters.database }}
cache: ${{ parameters.cache }}
- id: register
name: Register
action: catalog:register
input:
catalogInfoUrl: ./catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps['fetch-base'].output.repoUrl }}
- title: Open in Catalog
url: ${{ steps['register'].output.entityUrl }}
Custom Actions
// plugins/scaffolder-actions/src/actions/kubernetes.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-backend';
import { KubernetesClient } from '../kubernetes/client';
export function createDeployToK8sAction() {
return createTemplateAction({
id: 'kubernetes:deploy',
description: 'Deploys a service to Kubernetes',
schema: {
input: z.object({
manifestPath: z.string(),
namespace: z.string(),
image: z.string(),
replicas: z.number().default(1),
}),
},
async handler(ctx) {
const client = new KubernetesClient();
await client.applyManifest(ctx.input.manifestPath, {
namespace: ctx.input.namespace,
image: ctx.input.image,
replicas: ctx.input.replicas,
});
ctx.logger.info(`Deployed to ${ctx.input.namespace}`);
},
});
}
Integration Examples
GitHub Integration
// plugins/github-catalog-backend/src/provider.ts
import { GitHubEntityProvider } from '@backstage/plugin-catalog-backend-module-github';
export default function createGithubProvider() {
return GitHubEntityProvider.fromConfig(config, {
logger: env.logger,
githubToken: env.config.getString('github.token'),
orgs: env.config.getStringArray('github.orgs'),
catalogPath: 'catalog-info.yaml',
});
}
# app-config.yaml
catalog:
providers:
github:
- host: github.com
orgs:
- my-org
catalogPath: catalog-info.yaml
Jenkins Integration
// plugins/jenkins/src/components/JenkinsBuildsTable.tsx
import React from 'react';
import { useApi, jenkinsApiRef } from '@backstage/core-plugin-api';
import { useAsync } from '@backstage/core-utils';
export const JenkinsBuildsTable = ({ jobName }: { jobName: string }) => {
const jenkinsApi = useApi(jenkinsApiRef);
const { value, loading, error } = useAsync(() =>
jenkinsApi.getBuilds(jobName)
);
if (loading) return <Loading />;
if (error) return <Error error={error} />;
return (
<Table
title={`Jenkins Builds - ${jobName}`}`}
data={value || []}
columns={[
{ title: 'Build', field: 'number' },
{ title: 'Status', field: 'result' },
{ title: 'Duration', field: 'duration' },
{ title: 'Timestamp', field: 'timestamp' },
]}
/>
);
};
ArgoCD Integration
// plugins/argo-cd/src/components/ArgoCDApplications.tsx
import React from 'react';
import { Card, CardHeader, CardContent } from '@material-ui/core';
export const ArgoCDApplications = ({ applications }: { applications: any[] }) => {
return (
<Card>
<CardHeader title="ArgoCD Applications" />
<CardContent>
{applications.map(app => (
<div key={app.metadata.name}>
<span>{app.metadata.name}</span>
<StatusBadge status={app.status.sync.status} />
<StatusBadge status={app.status.health.status} />
</div>
))}
</CardContent>
</Card>
);
};
Search Integration
Configuring Search
// packages/app/src/components/Search/SearchPage.tsx
import React from 'react';
import { CatalogSearchResultListItem } from '@backstage/plugin-catalog';
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
import { SearchResult } from '@backstage/plugin-search';
export const SearchPage = () => {
return (
<SearchResult>
{({ results }) => (
<>
<CatalogSearchResultListItem
result={results.find(r => r.type === 'catalog')}
/>
<TechDocsSearchResultListItem
result={results.find(r => r.type === 'techdocs')}
/>
</>
)}
</SearchResult>
);
};
Custom Search Index
// plugins/search-backend-module-custom/src/index.ts
import { IndexableDocument } from '@backstage/plugin-search-common';
export class CustomDocCollator {
async execute(): Promise<IndexableDocument[]> {
const docs = await fetch('https://api.example.com/docs');
return docs.map(doc => ({
title: doc.title,
text: doc.content,
location: doc.url,
}));
}
}
Theming and Customization
Custom Theme
// packages/app/src/theme.ts
import { createTheme, ThemeOptions } from '@material-ui/core/styles';
const themeOptions: ThemeOptions = {
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
background: {
default: '#f5f5f5',
},
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
},
};
export const myTheme = createTheme(themeOptions);
Custom Homepage
// packages/app/src/components/HomePage/HomePage.tsx
import React from 'react';
import {
Content,
Header,
Page,
SupportButton,
Table
} from '@backstage/core-components';
export const HomePage = () => {
return (
<Page>
<Header title="My Company" subtitle="Developer Portal">
<SupportButton>Need help?</SupportButton>
</Header>
<Content>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<CardHeader title="Quick Links" />
<CardContent>
{/* Custom links */}
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardHeader title="Recent Deployments" />
<CardContent>
<DeploymentsTable />
</CardContent>
</Card>
</Grid>
</Grid>
</Content>
</Page>
);
};
Security
Authentication
# app-config.yaml
auth:
providers:
github:
clientId: ${AUTH_GITHUB_CLIENT_ID}
clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
google:
clientId: ${AUTH_GOOGLE_CLIENT_ID}
clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET}
permission:
enabled: true
rbac:
plugins:
catalog:
roles:
- name: admin
permissions:
- catalog:entity:read
- catalog:entity:create
Authorization
// Custom permission rule
import { createPermissionRule } from '@backstage/plugin-permission';
export const isProductionEnv = createPermissionRule({
name: 'IS_PRODUCTION_ENV',
description: 'Check if entity is in production',
resourceType: 'catalog-entity',
evaluate: async (resource, context) => {
return resource.spec?.lifecycle === 'production';
},
});
Deployment
Docker Setup
# Dockerfile
FROM node:18-bullseye
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
RUN yarn install --frozen-lockfile
COPY packages/app ./packages/app
COPY plugins ./plugins
RUN yarn build
CMD ["node", "packages/app/bin/start"]
# docker-compose.yaml
version: '3.8'
services:
backstage:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
depends_on:
- postgres
postgres:
image: postgres:14
environment:
POSTGRES_PASSWORD: changeme
Measuring Platform Success
Metrics to Track
interface PlatformMetrics {
// Adoption
activeUsers: number;
catalogCoverage: number;
templateUsage: number;
// Efficiency
selfServiceRate: number;
timeToFirstDeploy: number;
avgIncidentResponseTime: number;
// Quality
documentationScore: number;
securityPosture: number;
}
async function collectMetrics(backstage: Backstage): Promise<PlatformMetrics> {
const activeUsers = await getActiveUsers(); // From analytics
const catalogCoverage = await calculateCatalogCoverage(); // % of services in catalog
const templateUsage = await getTemplateUsage(); // Templates used this month
return {
activeUsers,
catalogCoverage,
templateUsage,
};
}
Conclusion
Backstage provides the foundation for building internal developer platforms that dramatically improve developer experience. Start with the software catalog to understand your landscape, add templates for standardization, and progressively add integrations and custom plugins.
The best platforms solve real problems. Start small, measure impact, and iterate. Your developers will thank you.
Comments