Backstage Implementation

Production engineering guide for backstage implementation covering patterns, implementation strategies, and operational best practices.

Backstage Implementation is the process of deploying, customising, and operating a Backstage instance to serve as the central developer portal within a platform engineering organisation. It matters when teams need a unified, discoverable, and extensible interface to internal services, tools, and documentation—especially at scale, when developers must navigate dozens of APIs, CI/CD pipelines, and infrastructure configurations. This page documents the actual steps, configurations, and gotchas observed in real Backstage deployments.

Setting Up the Core Backstage Instance

The foundational step is bootstrapping a Backstage instance using create-backstage-app or a docker-compose.yml file. The canonical entry point is a package.json with a start script and a app-config.yaml at the root.


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

backend:
  cors:
    origin: https://backstage.example.com
  database:
    client: postgres
    connection:
      host: db
      port: 5432
      user: backstage
      password: backstage-secret
      database: backstage
  cacher:
    type: redis
    connection:
      host: redis
      port: 6379
  service:
    port: 7000
  plugins:
    catalog:
      providers:
        github:
          catalogPath: /catalog-info.yaml
          hosts:
            github.com:
              token: ${GITHUB_TOKEN}

The catalog-info.yaml file is the primary source of truth for components, groups, and locations.

# catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: user-service
  description: User identity and profile management
  annotations:
    backstage.io/managed-by: engineering-team
spec:
  type: service
  owner: team-frontend
  lifecycle: production
  system: identity
  dependsOn:
    - auth-service
    - user-data-store

When the catalog fails to load, the most common error is a 404 on /api/catalog/entities with a log entry:

ERROR: Failed to load catalog from GitHub: Request failed with status code 404
  at fetchCatalogEntities (node_modules/@backstage/plugin-catalog-backend/src/processors/GitHubEntityProvider.ts:44:11)

This usually means the catalogPath in app-config.yaml is incorrect or the GitHub repository lacks a catalog-info.yaml file.

Adding and Managing Plugins

Plugins extend Backstage’s capabilities. To integrate the tech-radar plugin, add it to package.json and configure it in app-config.yaml.

{
  "dependencies": {
    "@backstage/plugin-tech-radar": "^0.12.0"
  }
}
techRadar:
  refreshIntervalSeconds: 3600
  groups:
    - name: "Frontend"
      items:
        - name: "React"
          level: 3
          date: 2023-06-01
        - name: "Svelte"
          level: 1
          date: 2023-08-15

The techRadar plugin expects a radar.json file at the root of the tech-radar directory. If this file is missing, the plugin logs:

WARN: Failed to load tech radar data: Could not find radar.json in /tech-radar

Plugins can be enabled in the app via App.tsx using createApp and addPlugin.

// App.tsx
import { createApp } from '@backstage/app-defaults';
import { techRadarPlugin } from '@backstage/plugin-tech-radar';

const app = createApp({
  plugins: [techRadarPlugin],
});

A common failure is forgetting to export the plugin’s router from its entry point. Without this, the route /radar returns 404, and the browser console shows:

Error: Cannot find module '@backstage/plugin-tech-radar'
    at Module.require (internal/modules/cjs/loader.js:988:19)
    at require (internal/modules/cjs/loader.js:1010:19)

Customising Entity Pages

Each component in the catalog can have a custom EntityPage. The EntityPage is composed from EntityLayout and EntityContent.

// components/EntityPage.tsx
import { EntityLayout } from '@backstage/plugin-catalog';
import { EntityTechDocsContent } from '@backstage/plugin-techdocs';
import { EntityGithubActionsContent } from '@backstage/plugin-github-actions';

export const EntityPage = () => (
  <EntityLayout>
    <EntityLayout.Route path="/docs" title="Docs">
      <EntityTechDocsContent />
    </EntityLayout.Route>
    <EntityLayout.Route path="/ci" title="CI/CD">
      <EntityGithubActionsContent />
    </EntityLayout.Route>
  </EntityLayout>
);

This layout is registered in App.tsx using EntityPage as a component.

The EntityTechDocsContent uses a techdocs service to serve documentation built from a mkdocs or docusaurus site. The service must be available at /api/techdocs and return a 200 with a Content-Type: application/json.

{
  "content": [
    {
      "title": "Getting Started",
      "path": "getting-started.html",
      "url": "https://docs.example.com/getting-started.html"
    }
  ]
}

When the techdocs service fails, the page shows:

Error: Failed to fetch techdocs: 503 Service Unavailable
  at fetchTechDocs (node_modules/@backstage/plugin-techdocs/src/components/EntityTechDocsContent.tsx:34:12)

The most common cause is a missing index.html in the documentation build output, or a misconfigured mkdocs.yml with an incorrect site_dir.

Integrating External Tools via APIs

Backstage integrates with external systems through its fetch API. To display a list of running Kubernetes pods from a custom plugin:

// plugins/kubernetes/src/api.ts
import { createApiRef } from '@backstage/core-plugin-api';
import { K8sPod } from './types';

export const kubernetesApiRef = createApiRef<KubernetesApi>({
  id: 'kubernetes.api',
});

export interface KubernetesApi {
  getPods(namespace: string): Promise<K8sPod[]>;
}

// Implementation
export class KubernetesClient implements KubernetesApi {
  constructor(private readonly baseUrl: string) {}

  async getPods(namespace: string): Promise<K8sPod[]> {
    const response = await fetch(`${this.baseUrl}/api/v1/pods?namespace=${namespace}`);
    if (!response.ok) {
      throw new Error(`Failed to get pods: ${response.status}`);
    }
    return response.json();
  }
}

The plugin registers this API in the app’s plugins.ts:

import { kubernetesApiRef } from './api';
import { KubernetesClient } from './api';

const kubernetesPlugin = createPlugin({
  id: 'kubernetes',
  apis: [
    kubernetesApiRef,
  ],
  routes: {
    root: '/kubernetes',
  },
});

The front-end component uses useApi to access the API:

// components/KubernetesPods.tsx
import { useApi } from '@backstage/core-plugin-api';
import { kubernetesApiRef } from '../api';

export const KubernetesPods = () => {
  const api = useApi(kubernetesApiRef);
  const [pods, setPods] = useState<K8sPod[]>([]);

  useEffect(() => {
    api.getPods('prod').then(setPods);
  }, []);

  return (
    <div>
      <h2>Running Pods</h2>
      <ul>
        {pods.map(pod => (
          <li key={pod.metadata.name}>{pod.metadata.name} ({pod.status.phase})</li>
        ))}
      </ul>
    </div>
  );
};

A subtle failure mode is a missing fetch polyfill in the browser. If the client uses fetch but the index.html lacks <script src="https://polyfill.io/v3/polyfill.min.js"></script>, the getPods call fails silently with TypeError: fetch is not a function.

Managing User Authentication

Authentication is handled via auth configuration in app-config.yaml. Backstage supports multiple providers: GitHub, Google, and OIDC.

auth:
  environment: production
  providers:
    github:
      development:
        clientId: abcdef123456
        clientSecret: xyz7890
        baseUrl: https://backstage.example.com
      production:
        clientId: ${GITHUB_CLIENT_ID}
        clientSecret: ${GITHUB_CLIENT_SECRET}
    google:
      development:
        clientId: 1234567890
        clientSecret: abcdefghijklmnop
        authUrl: https://accounts.google.com/o/oauth2/v2/auth
        tokenUrl: https://oauth2.googleapis.com/token
      production:
        clientId: ${GOOGLE_CLIENT_ID}
        clientSecret: ${GOOGLE_CLIENT_SECRET}

The login process uses OAuth2 with state and code parameters. A failure occurs when the state token is mismatched across requests. The most common error is:

Error: OAuth2 state mismatch: expected: abc123, got: xyz789

This happens when the auth cookie is not set or the state is not stored in the session. The solution is to ensure the auth plugin uses express-session or connect with cookie-session.

Extending the Catalog with Custom Locations

Backstage supports location-based discovery. A location is a source of catalog entities, such as a GitHub repository, a directory, or a URL.

catalog:
  locations:
    - type: url
      target: https://github.com/myorg/myrepo/blob/main/catalog-info.yaml
      rules:
        - allow: [Component, API, Group]
    - type: file
      target: /etc/backstage/catalog-extensions/*.yaml
      rules:
        - allow: [Component]

When a location fails to load, the catalog backend logs:

ERROR: Failed to process location: https://github.com/myorg/myrepo/blob/main/catalog-info.yaml
  at loadLocation (node_modules/@backstage/plugin-catalog-backend/src/processors/UrlReaderProcessor.ts:67:11)
  Caused by: Error: HTTP 404: Not Found

This indicates the target URL is incorrect or the file is not available at that path. The rules block filters entities by kind, and a common mistake is using kind instead of type in the rule.

Handling Plugin Dependencies and Versioning

Plugins may depend on specific versions of core libraries. The package.json must explicitly list these dependencies.

{
  "dependencies": {
    "@backstage/core": "^0.15.0",
    "@backstage/plugin-catalog": "^0.12.0",
    "@backstage/plugin-tech-radar": "^0.12.0"
  },
  "resolutions": {
    "@backstage/core": "0.15.0",
    "@backstage/plugin-catalog": "0.12.0"
  }
}

Using resolutions ensures that all plugins use compatible versions. Without it, version mismatches cause runtime errors:

TypeError: Cannot read property 'render' of undefined
    at EntityLayout (node_modules/@backstage/plugin-catalog/dist/index.js:123:22)
    at render (node_modules/react-dom/cjs/react-dom-server.node.js:345:18)

This occurs when one plugin uses @backstage/[email protected] and another uses 0.12.0, but the EntityLayout component is defined in the newer version.

Running in Production

The production deployment uses docker-compose.yml with nginx as a reverse proxy.

version: '3.8'

services:
  backstage:
    image: backstage/backstage:latest
    ports:
      - "7000:7000"
    volumes:
      - ./app-config.yaml:/app/app-config.yaml
      - ./plugins:/app/plugins
    depends_on:
      - db
      - redis
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgres://backstage:backstage-secret@db/backstage
      - REDIS_URL=redis://redis:6379
    command: ["start"]

  db:
    image: postgres:15
    environment:
      POSTGRES_USER: backstage
      POSTGRES_PASSWORD: backstage-secret
      POSTGRES_DB: backstage

  redis:
    image: redis:7-alpine

The nginx configuration routes / to the frontend, /api to the backend, and serves static assets.

server {
  listen 80;
  server_name backstage.example.com;

  location / {
    root /app/dist;
    index index.html;
    try_files $uri $uri/ /index.html;
  }

  location /api {
    proxy_pass http://backstage:7000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }

  location /static {
    alias /app/dist/static;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}

A silent failure in production is the static directory being empty. This results in missing CSS, JS, and images, with browser logs:

GET /static/main.abc123.js 404 (Not Found)
GET /static/app.abc123.css 404 (Not Found)

This happens when the build script is not run or when the dist directory is not volume-mounted correctly.

Summary of Sharp Edges

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.