Skip to main content

04 - Backstage.io: Platform Portal for Developer Self-Service and Service Catalog

Backstage is a framework to build and run, not a product. Catalog, TechDocs and Helm validated with real tools, plus the MkDocs risk.

What do you think about this article?

04 - Backstage.io: Platform Portal for Developer Self-Service and Service Catalog
Platform EngineeringPhoto:SiddePexels

At a Glance

Backstage is a framework to build and run, not a product. Catalog, TechDocs and Helm validated with real tools, plus the MkDocs risk.

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.yaml and 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

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

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects