What Backstage is and what it is not
Backstage is an open source framework (Apache-2.0 license) for building developer portals, born at Spotify. The CNCF accepted it on 8 September 2020 and since 15 March 2022 it has been an incubating project, still not graduated today (project page, consulted on 2026-11-13). The point to understand before anything else: it is a framework, not a product to install. You create an application with npx @backstage/create-app@latest, add the plugins in code, build it and deploy it. Whoever adopts it therefore runs a Node application with its own update cycle: the latest release on 2026-11-13 is 1.56.5 (GitHub releases page).
The central functions are three, and they coincide with the overview pages of the documentation: the Software Catalog, which keeps the inventory of components, APIs and resources with their owners; the Software Templates, which create new projects from the portal (I covered them with a complete example in article 3); and TechDocs, documentation written in Markdown next to the code and shown in the portal. I did not run create-app: it requires installing a whole project, and here I limit myself to what can be verified without it. In this article I validated the catalog and the configuration with the official packages, the documentation with mkdocs and the release with helm template.
How it is built: application, backend and plugins
An installation is made of three parts:
- The frontend application, a React page customized with themes, layouts and plugins.
- The backend, a Node.js process that exposes the APIs, talks to the database and hosts the server-side plugins; in the current backend system modules are added with
backend.add(import('...'))in the entry point. - The plugins, packages with a frontend part, a backend part or both; the catalog, the project generator and TechDocs are themselves plugins.
The practical consequence is that the portal has no panel to switch on an integration: every plugin is a dependency to install, configure and update together with the rest. The advantage is composability, the cost is that plugin versions must stay compatible with the core's.
Configuration lives in app-config.yaml. The following file is a minimal example for an installation with GitHub authentication and a PostgreSQL database; the sensitive values are environment variables, with the ${NAME} syntax. Two details matter. The signIn.resolvers block is indispensable: the documentation explains that every authentication provider, by default, only serves to delegate access to external systems, and that to let users in you must enable the login and say how to map the external identity onto a user entity in the catalog. And the «guest» provider included in create-app shares a single identity among everyone and must not be used in production.
app:
title: Example developer portal
baseUrl: https://backstage.example.com
backend:
baseUrl: https://backstage.example.com
listen:
port: 7007
database:
client: pg
connection:
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
integrations:
github:
- host: github.com
token: ${GITHUB_TOKEN}
auth:
environment: production
providers:
github:
production:
clientId: ${AUTH_GITHUB_CLIENT_ID}
clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
signIn:
resolvers:
- resolver: usernameMatchingUserEntityName
catalog:
rules:
- allow: [Component, System, API, Resource, Location, Group, User]
locations:
- type: url
target: https://github.com/example-org/catalogo/blob/main/catalog.yaml
To find out what happens when a variable is missing, I loaded the file with the official configuration loader (@backstage/config-loader 1.11.4) and read four required values, once with all the variables set and once without POSTGRES_PASSWORD. To get a clean environment I launched the script with env -i, which passes only the variables listed.
// Loads app-config.yaml with Backstage's configuration loader
// and checks the required values, one by one.
const path = require("path");
const mod = (n) => require(path.join(__dirname, "node_modules", n));
const { ConfigSources } = mod("@backstage/config-loader");
const OBBLIGATORI = {
"backend.database.connection.host": (c) => c.getString("backend.database.connection.host"),
"backend.database.connection.password": (c) => c.getString("backend.database.connection.password"),
"integrations.github[0].token": (c) => c.getConfigArray("integrations.github")[0].getString("token"),
"auth.providers.github.production.clientSecret": (c) => c.getString("auth.providers.github.production.clientSecret"),
};
(async () => {
const sorgente = ConfigSources.default({ argv: ["--config", process.argv[2]], remote: undefined });
const config = await ConfigSources.toConfig(sorgente);
for (const [chiave, leggi] of Object.entries(OBBLIGATORI)) {
try {
leggi(config);
console.log(`${chiave}: present`);
} catch (e) {
console.log(`${chiave}: ${e.message}`);
process.exitCode = 1;
}
}
config.close();
})();
// Output:
// [all variables set]
// backend.database.connection.host: present
// backend.database.connection.password: present
// integrations.github[0].token: present
// auth.providers.github.production.clientSecret: present
// exit: 0
// [without POSTGRES_PASSWORD]
// backend.database.connection.host: present
// backend.database.connection.password: Missing required config value at 'backend.database.connection.password' in 'app-config.yaml'
// integrations.github[0].token: present
// auth.providers.github.production.clientSecret: present
// exit: 1
Without the variable, the loader does not stop at startup: it replaces the placeholder with a missing value, and the error appears only when someone tries to read that value («Missing required config value»). A check like this, run in the pipeline before the release, turns a failure at startup into a rejection before the deploy. The GitHub integration token is the simple way; for an organization the documentation also proposes GitHub Apps, with tighter permissions.
Software Catalog: the descriptor and the relations
Every catalog entry is a YAML file, typically catalog-info.yaml in the repository root. There are nine entity kinds: Component, API, Resource, System, Domain, Group, User, Location and Template, the latter for project generators. The ones in everyday use are:
- Component: a service, a website, a library, a data pipeline.
- API: the boundary between components, in a machine-readable format (OpenAPI, gRPC, GraphQL).
- Resource: the infrastructure a component needs, for example a database.
- System: a set of components and resources that exposes one or more public APIs, and that makes it possible to hide internal details from consumers.
- Group and User: the people and teams that the other entities refer to as owners.
These are the definitions of the documentation's system model. The part that matters most in daily work is the «owner», and the fact that the spec.owner field is «for display purposes»: the documentation specifies that it must not be used to assign permissions in running systems. It is responsibility information, not a permission.
A catalog file contains many references between entities, and every reference is a string in the form [kind:][namespace/]name. If the kind is missing, a default applies that depends on the field: owner refers by default to a Group, dependsOn to a Component (for a resource you need to write resource:). The following example describes a small system with two teams, two components, two APIs and a database. The checker that goes with it uses the official validators of @backstage/catalog-model 1.10.2 for the schema, and then verifies that every reference points to an existing entity, which the schema does not check.
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: team-checkout
spec:
type: team
children: []
---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: team-inventario
spec:
type: team
children: []
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: e-commerce
spec:
owner: team-checkout
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: checkout-service
description: Handles the cart and the payment
annotations:
github.com/project-slug: example-org/checkout-service
backstage.io/techdocs-ref: dir:.
tags: [typescript, payments]
spec:
type: service
lifecycle: production
owner: team-checkout
system: e-commerce
providesApis: [checkout-api]
consumesApis: [inventario-api]
dependsOn:
- resource:checkout-db
- component:inventario-service
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: inventario-service
spec:
type: service
lifecycle: production
owner: team-inventario
system: e-commerce
providesApis: [inventario-api]
---
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: checkout-api
spec:
type: openapi
lifecycle: production
owner: team-checkout
system: e-commerce
definition: |
openapi: 3.0.3
info: {title: Checkout, version: 1.0.0}
paths: {}
---
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: inventario-api
spec:
type: openapi
lifecycle: production
owner: team-inventario
system: e-commerce
definition: |
openapi: 3.0.3
info: {title: Inventory, version: 1.0.0}
paths: {}
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: checkout-db
spec:
type: database
owner: team-checkout
system: e-commerce
The file shows three conventions. The annotations (github.com/project-slug, backstage.io/techdocs-ref) are keys read by the plugins: the schema accepts all of them, so a badly written annotation produces no errors but the corresponding plugin finds nothing. The two dependencies of checkout-service use different prefixes (resource: and component:) because the default for dependsOn is Component. And the APIs carry the definition inside the entry, whereas in real projects a reference to a file is often preferred.
// Validates a catalog file with the official Backstage validators and checks that every
// reference between entities (owner, system, API, dependencies) points to an entity that exists.
const fs = require("fs");
const path = require("path");
const mod = (n) => require(path.join(__dirname, "node_modules", n));
const YAML = mod("yaml");
const cm = mod("@backstage/catalog-model");
const VALIDATORI = {
Component: cm.componentEntityV1alpha1Validator, API: cm.apiEntityV1alpha1Validator,
Resource: cm.resourceEntityV1alpha1Validator, System: cm.systemEntityV1alpha1Validator,
Group: cm.groupEntityV1alpha1Validator,
};
// field -> default kind when the reference does not declare one (documentation rules)
const CAMPI = { owner: "Group", system: "System", providesApis: "API", consumesApis: "API", dependsOn: "Component" };
async function main(file) {
const entita = YAML.parseAllDocuments(fs.readFileSync(file, "utf8")).map((d) => d.toJS());
let errori = 0;
for (const e of entita) {
try { await VALIDATORI[e.kind].check(e); }
catch (err) { errori++; console.log(`${e.kind} ${e.metadata.name}: invalid schema (${err.message})`); }
}
const esistenti = new Set(entita.map((e) => cm.stringifyEntityRef(e)));
let riferimenti = 0;
for (const e of entita) {
for (const [campo, kind] of Object.entries(CAMPI)) {
for (const ref of [].concat(e.spec[campo] ?? [])) {
riferimenti++;
const completo = cm.stringifyEntityRef(cm.parseEntityRef(ref, { defaultKind: kind, defaultNamespace: "default" }));
if (!esistenti.has(completo)) { errori++; console.log(`${e.kind} ${e.metadata.name}: ${campo} -> ${completo} does not exist`); }
}
}
}
console.log(`${entita.length} entities, ${riferimenti} references, ${errori} problems`);
process.exitCode = errori ? 1 : 0;
}
main(process.argv[2]);
// Output:
// [catalog.yaml]
// 8 entities, 16 references, 0 problems
// exit: 0
// [catalog-sbagliato.yaml]
// Component checkout-service: owner -> group:default/team-chekout does not exist
// Component checkout-service: dependsOn -> resource:default/checkout-db does not exist
// 7 entities, 14 references, 2 problems
// exit: 1
The correct file passes: 8 entities and 16 references, no problems. In the second run I spoiled a copy by removing the database and writing team-chekout in place of the owner. The schema does not complain (the two entities are structurally valid), and it is the reference check that finds them both. That is why it pays to run a check like this in a pipeline, on the repository that holds the catalog: broken references are the most silent defect of an inventory, and it is the one that the catalog described in article 6 must avoid.
TechDocs: documentation next to the code, and a risk to know about
TechDocs takes the repository's Markdown files, builds them with MkDocs and shows them on the component's page. The link goes through the annotation backstage.io/techdocs-ref: dir:., which says that the documentation sits in the same folder as the descriptor. The advantage is that the documents are changed in the same change request as the code. For production the Backstage documentation recommends not having the backend build them: a pipeline step generates the static files with techdocs-cli and publishes them to cloud storage, from which the backend reads them.
The mkdocs.yml file below describes the site: a title, a navigation menu and the techdocs-core plugin, which prepares MkDocs for TechDocs. I built it with mkdocs 1.6.1 and mkdocs-techdocs-core 1.7.1 in a temporary virtual environment, with five sample pages, in --strict mode: every warning becomes an error. The second case is the same file with a menu entry that points to a page that does not exist.
site_name: Checkout Service
site_description: Technical documentation of the checkout service
nav:
- Home: index.md
- Architecture:
- Overview: architecture/overview.md
- Development:
- First start: development/getting-started.md
- Operations:
- Runbook: operations/runbook.md
- ADR:
- "001 - Choice of database": adr/001-database.md
plugins:
- techdocs-core
# Output:
# [mkdocs.yml]
# exit: 0
# [mkdocs-rotto.yml]
# WARNING - A reference to 'operations/runbook-mancante.md' is included in the 'nav' configuration, which is not found in the documentation files.
# Aborted with 1 warnings in strict mode!
# exit: 1
Without --strict a broken reference in the menu produces only a warning and the site is generated all the same, with a dead link: with the option, the pipeline stops. One clarification: to generate the output to publish, the TechDocs documentation asks you to use techdocs-cli (or the official container), because it adds a metadata file the reader needs. My check is valid for the site's structure, it does not replace that step.
The risk concerns the engine. On 17 April 2026 the Backstage maintainers opened a request for comments (RFC #33990, still open on 2026-11-13) in which they write that MkDocs has had no releases since version 1.6.1 of August 2024, that the proposed 2.0 rewrite would remove the plugin system (including mkdocs-techdocs-core) and that the Material theme TechDocs relies on is in maintenance mode only; they propose moving to Zensical, an engine that reads mkdocs.yml. The warning that the Material package prints at every build, and that appears in my runs, points in the same direction. In practice: pin the MkDocs and theme versions, generate the documentation in a pipeline and do not use exotic extensions, so that on the day of the engine change the work stays contained.
Deploying Backstage on Kubernetes with Helm
The documentation describes the method Spotify uses: build a Docker image, publish it to a registry, cite it in a Kubernetes Deployment and apply it. A community-maintained Helm chart also exists in the backstage/charts repository (version 2.10.2 on 2026-11-13). Here the point is not the chart but the values: they are the schema to adapt, and I rendered them with helm template (helm 3.16.0, no cluster), which produces the manifests without applying them.
backstage:
image:
registry: ghcr.io
repository: example-org/backstage
tag: "1.0.0"
replicas: 2
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
extraEnvVars:
- name: POSTGRES_HOST
valueFrom:
secretKeyRef:
name: backstage-db
key: host
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: backstage-github
key: token
ingress:
enabled: true
className: nginx
host: backstage.example.com
tls:
enabled: true
secretName: backstage-tls
postgresql:
enabled: false
# Output (helm template backstage backstage/backstage --version 2.10.2 -f values-backstage.yaml | grep -E ...):
# kind: Service
# kind: Deployment
# replicas: 2
# image: ghcr.io/example-org/backstage:1.0.0
# kind: Ingress
# ingressClassName: "nginx"
# secretName: backstage-tls
# - host: backstage.example.com
Four choices in the file. The image has a fixed tag and not latest, because the chart's default value is precisely latest and a restart could bring a different version from the one tested. The credentials come from secretKeyRef, not written in the file. The ingress block sits at the root of the values and not under backstage, and tls is an object with enabled and secretName, not a list as in the Kubernetes Ingress. Finally postgresql.enabled: false: the included PostgreSQL subchart uses by default the image docker.io/bitnamilegacy/postgresql, and for a portal that holds the catalog a database managed by you is better, with the backups that follow.
The third choice is also the easiest mistake to make. With the values below, which put the Ingress inside backstage, the chart rejects the file:
backstage:
image:
registry: ghcr.io
repository: example-org/backstage
tag: "1.0.0"
replicas: 2
ingress:
enabled: true
host: backstage.example.com
tls:
- secretName: backstage-tls
hosts:
- backstage.example.com
# Output (helm template ... -f values-sbagliato.yaml):
# Error: values don't meet the specifications of the schema(s) in the following chart(s):
# backstage:
# - backstage: Additional property ingress is not allowed
The chart has a values schema that rejects unknown keys: an error that with other charts would pass silently and leave the application without an Ingress. The message names the wrong key, not the cause, so it helps to know where to look. Validating the generated manifests against the Kubernetes schema, with kubeconform, is the next step and I showed it in article 2.
When not to install Backstage: a catalog without a portal, and where it gave way
This site's repo does not use Backstage, and it is worth saying why with an example. It has three services, a single
maintainer and a catalog to manage by hand: the documents in docs/ with an index by aspect,
a CLAUDE.md file that maps them, and a content registry. There is no portal because the
readers of the catalog are one (the author) and the automated agents working on the repo, and for both a
well-indexed text file is cheaper than a Node application with its database.
The cost of a portal, counted
- One more service to run and update. Backstage is a Node app with a backend, a database and plugins with their own versions. Updating a plugin broken by a core update is recurring work.
- A data model to maintain. Every component needs a
catalog-info.yamland an owner. With three services the descriptors would cost more than the search saves. - The benefit grows with the number of owners. The value of the catalog is answering «whose is this service and how do I use it»: it pays off when the answer is not in the reader's head.
The three items have one thing in common: they are costs paid every month and not once, while the benefit appears only when someone really looks something up in the catalog. Before installing a portal it is worth estimating how often, and by how many different people, the answer to the question «whose is this service and how do I use it» is looked up.
Where the manual catalog gave way: two sources of truth
The defect that a real catalog prevents showed up all the same. The business tools catalog is a JSON file in two copies: a canonical one in the backend and one in the frontend, copied over the second at every build by a prebuild script. Whoever changed only the frontend copy saw the change overwritten without an error: the new tools did not appear on the catalog page and no test failed. The rule written in the project is «always add to the backend file, then regenerate the copy». It is the same thing Backstage solves with the principle of a single origin for the entity, obtained here with a rule and a script instead of a product.
The dividing line. A portal is justified when services outnumber what one person keeps in mind and when at least three teams need to discover the others' tools (an indicative threshold, mine, not measured). Below that threshold the useful question is not «which portal» but «where does the single copy of each piece of data live, and what happens if someone changes the wrong one», and the answer is a test that fails, not a portal.
What to install first
Start with the minimum: catalog, TechDocs and two or three plugins that answer a question your developers really ask (usually the pipeline status and where the service runs). Add one plugin at a time, based on requests, and after each addition check two things: how much the update costs and whether anyone uses it. A portal that is too rich at launch confuses instead of helping, and that is my own judgment, not a research result. The metrics to tell whether the portal is serving are those of article 7.
What you can do now
You know that Backstage is a framework to build and run, and with which parts (application, backend, plugins). You can write a catalog and verify its schema and references with the official validators, check in a pipeline that the configuration is complete, and protect the TechDocs site from a broken menu. You can set the Helm values for a release and recognize the two typical mistakes (Ingress in the wrong place, floating tag). The next article widens the view to infrastructure as code, the part the portal calls when a request must create something.
Frequently asked questions
Is Backstage a product to install?
No, it is a framework: you create a Node application with @backstage/create-app, add the plugins in code and deploy it. Whoever adopts it manages its updates and the compatibility between plugins and core.
What does the Software Catalog do?
It keeps the inventory of components, APIs, resources, systems and teams, described by YAML files in the repositories. The owner field indicates responsibility and is for display purposes: it does not assign permissions in running systems.
How do you deploy Backstage in production?
The documentation describes building a Docker image, publishing it to a registry and a Kubernetes Deployment; a community Helm chart also exists. You need a PostgreSQL database, an authenticator with identity resolution and a fixed image tag.
Is TechDocs at risk because of MkDocs' future?
It is a risk declared by the Backstage maintainers: MkDocs has had no releases since 1.6.1 (August 2024) and the proposed 2.0 version would remove plugins. An RFC opened in April 2026 evaluates moving to Zensical. It pays to pin versions and generate the documentation in a pipeline.
The same series also includes the introduction to the internal platform, developer experience and feedback loops, the IDP implementation plan and the comparison between on-prem and cloud AI.
Sources
- Backstage: Descriptor Format of Catalog Entities -
spec.ownerandspec.dependsOnfields, default kinds, purpose of the owner field, consulted on 2026-11-13 - Backstage: System Model - definition of Component, API, Resource, System, Group and User, consulted on 2026-11-13
- Backstage: Entity References - the
[kind:][namespace/]nameform, consulted on 2026-11-13 - Backstage: Sign-in Identities and Resolvers - login with identity resolution, guest provider not for production, consulted on 2026-11-13
- Backstage: TechDocs Architecture - recommended deployment with generation in a pipeline and cloud storage, use of
techdocs-cli, consulted on 2026-11-13 - backstage/backstage, issue 33990: RFC on Zensical as the TechDocs engine - state of MkDocs and the maintainers' proposal, consulted on 2026-11-13
- Backstage: Deploying Backstage - the method with Docker image and Deployment, consulted on 2026-11-13
- backstage/charts - Helm chart 2.10.2, values structure and schema, consulted on 2026-11-13
- CNCF: Backstage - acceptance date (2020-09-08) and date of the move to incubation (2022-03-15), consulted on 2026-11-13
The scripts were run twice with identical output (Node 24 with @backstage/catalog-model 1.10.2, @backstage/config-loader 1.11.4 and yaml; mkdocs 1.6.1 with mkdocs-techdocs-core 1.7.1; helm 3.16.0 with helm template, without a cluster). create-app, starting Backstage and the release on a cluster were not run: the Helm values are a schema to adapt. Organization and domain names are invented.

Commenti
Caricamento commenti...
Sign in to comment