Backstage is an open framework for building internal developer portals. A developer portal is a single interface over the things an engineering organisation already runs: a catalog of every service and who owns it, a way to create new services consistently, and the documentation for both.
It started at Spotify in 2016 as an internal tool called System Z, a directory of components that recorded a link to the code, the team that owned it, and the product owner. It grew to cover relationships between components and groupings of services into systems, and it became the natural place to put web frontends for internal tooling that until then had only had a CLI. That growth led to a rewrite in 2017, and that rewrite is what became Backstage.
Spotify open-sourced it in March 2020. At the time of the announcement, the internal version was used by over 280 engineering teams to manage more than 2,000 backend services, 300 websites, 4,000 data pipelines, and 200 mobile features. It entered the CNCF Sandbox on 8 September 2020, moved to Incubating on 15 March 2022, and is still at Incubating today, with graduation a stated goal. Over 3,400 companies now run it.
If you played Dead Reckoning, you have already spent a weekend inside it. There is a section on that further down.
The three surfaces
Backstage ships as a framework, not a product. Almost everything in it is a plugin, including the three surfaces below.
The software catalog
The catalog is a registry of everything your engineering organisation runs: services, libraries, websites, pipelines, teams, APIs. Each entity is described by a catalog-info.yaml file that lives in the repository it describes.
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-service
annotations:
github.com/project-slug: acme/payment-service
spec:
type: service
lifecycle: production
owner: payments-team
system: checkout
The kind field selects from a fixed set of entity types:
| Kind | What it models |
|---|---|
Component |
Services and libraries you build |
API |
The contracts between components |
Resource |
Infrastructure a component needs: databases, buckets, queues |
System |
A group of related components and resources |
Domain |
A group of related systems |
User and Group |
Your org chart |
Template |
A scaffolder template |
Location |
A pointer to more catalog-info.yaml files |
Relationships are declared in the spec, and the catalog builds a directed graph from them. The field names are not interchangeable:
spec.dependsOnpoints at Components and Resourcesspec.providesApisandspec.consumesApispoint at APIsspec.subcomponentOfnests a Component inside anotherspec.ownerpoints at a User or Groupspec.systemplaces a Component or Resource in a System
That graph is what powers the dependency view, the ownership model, and the impact surface you see when a service goes down. Mixing up dependsOn and consumesApis produces a catalog that renders fine and answers questions wrongly.
How entities actually get in
Ingestion happens in two stages, and confusing them is the source of most catalog problems.
Entity providers sit at the edge of the catalog and are the original source of entities. Each provider manages its own private bucket of entities and can add, update and delete within it. Static locations in app-config.yaml and the dynamic location store used by the catalog import UI are both built-in providers. Provider output is a set of unprocessed entities. Timing is up to the provider, so it is detached from everything downstream.
Processors then run in a fixed loop over every entity. A processor takes one unprocessed entity and returns modifications to it, plus optionally more unprocessed child entities. This is how a Location entity pointing at a URL turns into the Components described at that URL: the Location is processed, it emits children, and those children are processed in turn. The result is a tree, and the final entity you query is the stitched product of every processor that touched it.
Two consequences follow from that design.
The loop is unconditional. Every registered processor runs against every entity on every pass, and the only thing you control is the interval, set with catalog.processingInterval, which applies equally to all of them. A processor that makes a network call makes that call for every entity, forever. The Backstage docs are blunt about this: a processor should do the minimum possible work and hand control straight back.
Processors cannot delete. If a processor stops emitting a child it previously emitted, that child is not removed, it becomes an orphan. Only providers can delete, and when a provider removes something from its bucket, the subtree processed out of it goes too, but only where those entities would otherwise be orphaned. This is why the docs recommend writing a custom entity provider rather than a custom processor when integrating an external system. Providers own their data and can retract it, processors cannot.
When processing fails, the entity carries a status field describing the problem. It surfaces in the UI only if the entity page includes EntityProcessingErrorsPanel, which is worth checking early, because without it failures are silent.
Software templates (the scaffolder)
The scaffolder is a pipeline engine for creating new things consistently. A template collects input through a form, then runs a sequence of actions: render files from a skeleton, create a repository, push to it, register the result in the catalog, open a pull request. Anything scriptable can be an action.
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: node-service
title: Node.js service
spec:
owner: platform-team
type: service
parameters:
- title: Service details
required: [name, owner]
properties:
name:
type: string
owner:
type: string
ui:field: OwnerPicker
steps:
- id: fetch
name: Render files
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
- id: publish
name: Create repository
action: publish:github
input:
repoUrl: github.com?repo=${{ parameters.name }}&owner=acme
- id: register
name: Register in catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
Templates use scaffolder.backstage.io/v1beta3, not the catalog’s backstage.io/v1alpha1. A template is itself a catalog entity of kind: Template, which is how the scaffolder finds it.
There are three sections. parameters is a JSON Schema form definition, with ui:field selecting custom widgets like OwnerPicker. steps is the pipeline, where each step reads previous output through ${{ steps.<id>.output.<field> }}. output is what the user sees on the success screen.
The Template Editor at /create/edit gives you a live preview and a dry run. The dry run executes the template without creating anything real, which is how you debug a template without commissioning a service on every attempt. Actions opt into this by setting supportsDryRun and checking ctx.isDryRun in their handler.
The built-in action library covers GitHub, GitLab, Bitbucket, Azure DevOps and more. Installed actions are listed at /create/actions, generated from each action’s schema, which is the fastest way to find out what a given action accepts.
TechDocs
TechDocs renders Markdown from a service’s repository into the Backstage UI. It is built on MkDocs with a mkdocs-techdocs-core plugin handling the rendering pipeline. A service needs an mkdocs.yml and a backstage.io/techdocs-ref annotation on its catalog entity, and the docs then appear on the entity page next to the service they describe. The docs live with the code and are versioned with it.
TechDocs has two build modes and the difference matters. In local mode the backend builds documentation on demand. It needs no extra infrastructure and is intended for development only. In external mode, docs are built in CI and published to object storage, and the backend only reads them. Running local in production means every documentation request triggers a build on the backend.
The plugin system
The catalog, the scaffolder and TechDocs are all plugins. A plugin has a frontend React package and an optional backend package. Frontend plugins add pages, cards and tabs. Backend plugins add routes, event handlers and integrations.
Both halves have been rebuilt, and this is where most tutorials you find will send you wrong.
The backend. The new backend system arrived in 2023 with a dependency injection model: plugins declare what they need and the backend wires it together at startup. This is no longer optional. @backstage/backend-common and @backstage/backend-tasks were removed in v1.32, and support for the legacy backend system was removed entirely in v1.42. A tutorial that calls createRouter or imports backend-common will not run at all. The current pattern is createBackendPlugin and createExtensionPoint.
The frontend. The New Frontend System reached its 1.0 release candidate in v1.49 on 18 March 2026 and is the default for newly created apps. The --next flag on create-app was replaced by a --legacy flag for apps that have not migrated. createApp now comes from @backstage/frontend-defaults, extensions are declared with blueprints, and yarn new detects which frontend system your app uses so it offers the matching plugin template.
These are two separate systems with separate migration paths. createPlugin was the frontend API, createRouter was the backend one, and seeing both named in one tutorial is a reliable signal that it predates both rewrites.
The ecosystem
Most plugins no longer live in the core repository. They were moved to backstage/community-plugins and publish under the @backstage-community namespace, so that plugin maintenance is decoupled from the core release cycle. That repo is where new plugin contributions are directed.
PagerDuty, Datadog, SonarQube, Grafana, Kubernetes, Cost Insights, Lighthouse, GitHub Actions, Argo CD, Vault. Three places to look: the official Plugin Marketplace, the community-plugins repository, and the Roadie plugin directory. Ignore anything pointing at janus-idp/backstage-plugins, which is archived.
You do not have to self-host. Spotify sells Spotify Portal for Backstage, Red Hat ships Developer Hub, and Roadie offers a managed instance. All three are Backstage underneath, with the setup and upgrade work handled for you.
What it takes to run it
Three costs, in increasing order of how much they will surprise you:
- Dependencies. Node and Yarn, both pinned to specific versions in the getting started guide, so check that page rather than assuming. Storage defaults to SQLite, which does not persist and suits local development only. Anything real needs PostgreSQL.
- Auth and permissions. Backstage ships both an auth system and a permission framework, and neither is configured out of the box. A default app has no meaningful access control. Budget for this rather than meeting it during a security review.
- Upkeep. Releases land roughly monthly, both rewrites above happened inside a three-year window, and staying current is ongoing work rather than a one-off migration.
In practice: the adventures on OffOn
Backstage sat at the centre of Dead Reckoning. All three levels broke it, each one further down the chain than the last.
The beginner level put you in front of a broken template.yaml. The template had a misconfiguration spread across its three sections: the parameter form, the steps pipeline, and the output block. Fixing it meant reading the template against the scaffolder action documentation and the Template Editor’s dry run output together. A misconfigured step does not always fail where the mistake is, because a step can produce output that looks valid until something downstream tries to use it.
The intermediate level extended the chain. A correctly commissioned service should have triggered a Gitea webhook into Argo Events, which should have submitted an Argo Workflow that built and pushed a container image, which should have updated the deployment manifests, which Argo CD should have synced into the cluster. Backstage was the entry point. Finding where the chain stalled meant following the data from each tool’s UI to the next.
The expert level added distributed tracing across the whole pipeline. Backstage opens the root span when a commission starts and passes the trace context through to the pipeline, and the goal was a single connected trace from the commission office to the running service. Fixing the propagation required understanding how W3C traceparent headers travel across async boundaries, specifically a git push that Argo Events reacts to after a delay. The OpenTelemetry post on 18 August goes into that.
How to start contributing
Start with the contributing guide. Four things apply to every contribution regardless of which path you take.
Sign your commits. Backstage uses the Developer Certificate of Origin rather than a CLA, so every commit needs a Signed-off-by trailer. git commit -s adds it, provided user.name and user.email are set in your git config. CI fails the pull request without it.
Complete the pull request template. All of it, including the screenshots and the checklist.
If you use an LLM, read the guide’s rules on it first. Backstage added an explicit policy. Verify that everything you write is accurate whether or not a model generated it, do not misrepresent what your change does or how you tested it, and do not overwrite the PR template with generated text. Linking to primary sources is more useful than quoting a model. Maintainers cannot review a contribution whose description does not match the code.
Plugins have a second guide. community-plugins has its own requirements on top of the main ones, including running yarn build:api-reports to regenerate the API reports, which CI fails without, and Changesets for versioning.
Custom scaffolder actions
This is the smallest useful contribution and the best entry point. An action is one function.
Create a scaffolder module with yarn backstage-cli new, selecting scaffolder-module. You get a plugin directory with an example action already in place. The action itself looks like this:
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import fs from 'fs-extra';
import { type z } from 'zod/v3';
export const createNewFileAction = () => {
return createTemplateAction({
id: 'acme:file:create',
description: 'Create an Acme file.',
schema: {
input: {
contents: z => z.string({ description: 'The contents of the file' }),
filename: z =>
z.string({ description: 'The name of the file to create' }),
},
},
async handler(ctx) {
await fs.outputFile(
resolveSafeChildPath(ctx.workspacePath, ctx.input.filename),
ctx.input.contents,
);
},
});
};
Four things are worth noting:
- The schema is defined per key as a function receiving
z. Backstage converts it to JSON Schema for the rest of the system, which is what renders the entry at/create/actions. ctx.workspacePathis the scaffolder’s temporary working directory, andresolveSafeChildPathis what stops a template writing outside it.- The handler is ordinary async TypeScript, so anything your stack exposes over an API can become an action.
- The naming convention is
provider:entity:verb, namespaced so custom actions do not collide with built-ins added later.
If your team has internal tooling the scaffolder should be able to call, that action is a self-contained contribution that does not require understanding the rest of Backstage.
Plugins
Scaffold an app with npx @backstage/create-app@latest, then create the plugin from inside it with yarn new, which is a shortcut for backstage-cli new --select plugin. Note the split: create-app builds the app, yarn new builds the plugin. New community plugins go to the community-plugins repository rather than core.
Documentation
The highest-value area at the moment, particularly around the two rewrites. The official docs are ahead of the wider tutorial ecosystem, and the gap between them is where most people get stuck.
Core
The good first issue label is maintained on the main repository. The community runs monthly open-mic sessions and SIG calls listed in the backstage/community repository, which is worth attending once before picking something up.
Where to go next
- backstage.io for the docs, the getting started guide and the plugin marketplace
- The Life of an Entity and External Integrations for the ingestion pipeline in full
- Writing Custom Actions for the scaffolder
- New Backend System and New Frontend System, plus their migration guides
- v1.42 release notes for the legacy backend removal, v1.49 for the frontend default switch
- backstage/backstage for issues, the contributing guide and the ADRs under
docs/adr/ - backstage/community-plugins if you are writing a plugin
- Certified Backstage Associate, the CNCF certification, for a structured path through the material
- Backstage Weekly for tracking changes in the ecosystem
Next week: Kyverno, policy-as-code for Kubernetes, and why the broken admission rules in Lex Imperfecta were so hard to see.