Skip to main content
โšก Calmops

Platform Engineering with Backstage: Complete Guide 2026

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

// 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.

Resources

Comments