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.
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.
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)
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.
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.
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.
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.
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.
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.
catalog-info.yaml must be at the root of the repository, not in ./catalog/.EntityLayout.Route components must return JSX.Element, not ReactNode, to avoid TypeError: component.render is not a function.techdocs service must return Content-Type: application/json and a content array.fetch is not available in all environments; polyfills are required for older browsers.resolutions in package.json prevent version drift across plugins.app-config.yaml variables are resolved at startup, so changes to environment variables require a restart.nginx must serve index.html for client-side routing; without try_files, routes like /entity/user-service return 404.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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy