In Depth · The Backstage Portal Model

The Backstage Portal Model

The CBA blueprint tests four domains you can study a checklist into: customizing plugins, running the development workflow, standing up the infrastructure, and populating the catalog. Read at that level, Backstage looks like a big, opinionated web app with a database and some YAML conventions. What the blueprint states but doesn't have room to justify is the design decision underneath all four domains: Backstage ships as a framework you fork, compile and own, not a product you point a browser at and configure. This page goes past the exam into why that choice was made, what "own" actually costs a team over years rather than weeks, why the catalog is better read as a graph than a list, and what the scaffolder is really wiring together when a developer clicks one button. If you haven't sat the exam-depth material yet, start with CBA — the exam and come back — this page assumes you already know the four domains and their weights.

☺ Explain it like I'm 10

Remember the front desk from the CBA page — one place with an index of everything in the workshop? Here's the part that page didn't have room for. Nobody delivers you a finished front desk. A courier drops off a box of parts, an instruction booklet, and a note that says "you build this counter, you paint it, you fix the drawer when it sticks, forever" — that's the framework choice, made on day one. And the index that desk keeps isn't a plain list, it's more like a family tree that draws its own lines: tell it "this project belongs to that team," and the tree automatically also knows "that team owns this project," without anyone writing it down twice. A golden path is a pre-filled family-tree form sitting on the counter — fill in one name, and the tree grows a correctly-connected new branch by itself, joints and all.

🦋Your host for this topic: Mira the Butterfly — the same transformer who hosts the CBA exam pages, back here for the design decisions the exam assumes you'll take on faith rather than the exam itself.

What the blueprint states, and the question it leaves open

☺ Like you're 10: The exam wants one fact memorized: it's a framework, not a product. This page wants you to feel what that costs and what it buys.

Quick recap, at exam depth, so this page stands on solid ground: the CBA's Infrastructure domain names "Understand the Backstage framework" as its very first competency, and the CNCF curriculum is unusually blunt about what that means in practice — you run npx @backstage/create-app, you get a Yarn workspace containing packages/app and packages/backend, and from that moment the code in that workspace is yours: your repository, your CI, your TypeScript compiler, your Docker build. Nothing about that sentence describes installing software. It describes generating a starting point for software you are now responsible for. CBA — the exam covers the domains this implies — the development workflow, the infrastructure, the plugin architecture — at exactly the depth a ninety-minute paper rewards.

What the blueprint doesn't ask you to sit with is why the Backstage project made that call, or what it actually changes about a platform team's roadmap for the next three years compared to buying a portal off a shelf. Those are the questions this page answers, along with the two ideas that fall out of taking "framework" seriously: the catalog as a graph rather than a list, and the scaffolder as the mechanism that keeps new nodes correctly wired onto that graph without anyone doing it by hand.

A framework you own, not a product you install

☺ Like you're 10: A product comes with someone else's promise to keep it running. A framework comes with the promise that you now know how, and it's your turn.

Most software a platform team adopts is a product: a vendor operates it, ships upgrades on their schedule, patches its own security holes, and hands you a support contract if it breaks. Backstage is structurally different, and the difference is visible in the first command anyone runs. create-app doesn't install a binary or provision a SaaS tenant — it generates a monorepo, into your Git history, that you now compile, test, containerize and deploy using your own pipeline. The CNCF and Linux Foundation publish the framework's source and the CBA curriculum; neither operates a hosted instance for you to log into. A small number of vendors sell managed Backstage hosting or an opinionated distribution built on top of it, and that's a legitimate way to buy back some of the operational burden — but even then, what you're buying is help operating your fork, not a subscription to somebody else's running service the way you'd buy a SaaS issue tracker.

"Own" is not a slogan here — it names four concrete, recurring bills a product would have absorbed for you. You carry the TypeScript and dependency upgrade cycle: every @backstage/* package should move together, npx @backstage/cli versions:bump exists because they drift otherwise, and a portal left untouched for a year is a real migration project, not a button. You carry the database: PostgreSQL, its backups, its migrations, are infrastructure your team runs, not a schema a vendor manages behind an API. You carry identity and authorization: which auth provider signs people in, and which policy decides who may edit which entity, are both pluggable extension points Backstage deliberately ships unopinionated — the framework gives you the seam, not the answer. And you carry every plugin's blast radius: installing a community plugin means running its code, frontend and backend, inside your monorepo and your cluster, with the same trust you'd extend to code your own team wrote.

◆ Key idea

The single most load-bearing fact about Backstage is that it is a framework, not a product — and every other CBA competency is downstream of that one sentence. A framework hands you the seams (plugin architecture, auth provider interface, entity provider interface, permission policy hook) and leaves the decisions at each seam to you. That's not a limitation the project apologizes for; it's the entire design bet — that a portal generic enough to fit any organization's identity system, database, and internal tooling has to be something you assemble, not something anyone could plausibly sell pre-assembled.

The catalog as a graph, not a list

☺ Like you're 10: It looks like a folder of index cards. It behaves like a family tree that draws half its own lines.

The CBA teaches the catalog's nine entity kinds and enough relation vocabulary to answer a multiple-choice question about ownership. What that leaves implicit is the shape of the whole structure: every entity is a node, and every relationship between two entities is a directed edge, which makes the catalog, formally, a directed graph rather than a table of records. That reframing is not decorative. A list answers "what exists." A graph answers "what does this depend on," "who owns everything under this system," and "if I delete this database, what breaks" — questions a flat table can't answer without a second, hand-maintained spreadsheet next to it.

The mechanic that makes the graph trustworthy rather than a chore is that most edges are written once and appear twice. A human declares one direction of a relationship in a single YAML file; the catalog-processing pipeline computes and stores the opposite direction automatically, on every entity the declaration touches. Declare spec.owner: group:payments-team on a Component, and the catalog derives an ownerOf edge on the Group pointing back — nobody edits the Group's file to say "and I own this." Declare spec.dependsOn on a database resource, and the resource gains a dependencyOf edge back to the component, with no second commit anywhere. This is the property that keeps a catalog with thousands of entities from silently going stale in one direction while staying current in the other — there is only ever one place a human can get it wrong.

One declared field, five graph edges every gold arrow is written by a human once — the teal dot marks the edge Backstage adds for free System: commerce hasPart ← derived Component: checkout the entity a human actually edits kind: Component · owner: payments-team Resource: checkout-db dependencyOf ← derived Group: payments-team ownerOf ← derived hasMember ← derived User: alice member of the group API: checkout-api apiProvidedBy ← derived partOf dependsOn ownedBy providesApi memberOf derived edge (automatic) declared edge (a human wrote this)

Five declared fields on one YAML file — owner, system, dependsOn, providesApis, and a User's memberOf one hop away — produce ten live edges once the reverse relations are counted. That ratio is the actual argument for treating the catalog as infrastructure rather than documentation: documentation goes stale because nobody remembers to update the second copy; a graph with computed reverse edges structurally can't develop that particular kind of staleness, because there is no second copy for anyone to forget.

You write this fieldOn this kindDeclared edgeDerived edge (automatic, on the target)
spec.ownermost kindsownedByownerOf
spec.systemComponent, API, ResourcepartOfhasPart
spec.dependsOnComponentdependsOndependencyOf
spec.providesApisComponentprovidesApiapiProvidedBy
spec.memberOfUsermemberOfhasMember

Golden paths: the scaffolder as the on-ramp onto the graph

☺ Like you're 10: The "Create" button doesn't just hand you a folder of files. It hands you a folder of files that already knows whose family it belongs to.

The CBA tests the scaffolder as a development-workflow feature: you know that fetch:template, publish:github and catalog:register are real action IDs, and that a template's parameters become a form. What that framing undersells is what those three actions are doing together: they are the mechanism by which a brand-new service is born already correctly wired into the graph from the section above, rather than born as an orphan a human has to remember to connect later. A golden path, in the platform-engineering sense this course shares with Platform Engineering's own material, is a paved road — the easiest way to do a thing is also the correct way to do it — and the scaffolder is where that principle becomes executable rather than aspirational.

# templates/new-service/template.yaml — the "New Service" button in the catalog
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: new-service
  title: New Service (Golden Path)
  description: A service with CI, a Dockerfile, and a correctly-wired catalog entry.
spec:
  owner: group:platform-team
  type: service
  parameters:
    - title: Basic information
      required: [name, owner, system]
      properties:
        name: { type: string, title: Service name }
        owner:
          type: string
          title: Owning team
          ui:field: OwnerPicker          # only real Group/User entities are selectable
        system:
          type: string
          title: Parent system
          ui:field: EntityPicker
          ui:options: { catalogFilter: { kind: System } }
  steps:
    - id: fetch
      name: Fetch skeleton
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}     # written straight into catalog-info.yaml
          system: ${{ parameters.system }}   # by the skeleton's own Nunjucks templating
    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        repoUrl: github.com?repo=${{ parameters.name }}&owner=acme
    - id: register
      name: Register in the catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml

The detail worth sitting with is inside fetch:template: the skeleton's own catalog-info.yaml is itself a Nunjucks template, and the form's owner and system answers get substituted straight into it before the repository is ever created. That means the very first commit to the new repository already declares spec.owner and spec.system correctly — the graph edges from the previous section exist before catalog:register even runs, because they were written into the file the moment the developer submitted the form, not added later by hand.

From one form to a wired-in graph node ① Fill out the form name, owner, system the developer's only job ② fetch:template renders catalog-info.yaml owner + system already filled in ③ publish:github creates the real repo file already correct on commit 1 ④ catalog:register creates a Location entity pointing at that file's URL ⑤ next processing pass Location → Component entity derived relations computed already wired to System + Group next click, same paved road — nobody hand-wires the graph
⚠ A golden path is paved, not gated

The scaffolder makes the correct thing easy; it does not make the incorrect thing impossible. Nothing stops a developer from writing catalog-info.yaml by hand and registering it with a made-up owner that doesn't exist as a Group, or skipping the template entirely. Making a shortcut like that actually fail, rather than merely be discouraged, is a policy engine's job, not the scaffolder's — see Policy-as-Code Philosophy and KCA for the enforcement layer that golden paths deliberately don't try to replace.

Extending the graph: writing your own provider

☺ Like you're 10: The exam teaches you to point the index at GitHub. Real workshops usually have more than one shelf worth indexing.

The CBA's Catalog domain tests using an ingestion provider — pointing a GitHub organization provider at a set of repositories and reasoning about what it does on its schedule. It does not test writing one, and most platform teams eventually do, because the interesting half of their inventory usually doesn't live in a Git organization at all: an internal fleet registry, a cloud account inventory, an on-call system's service list. Backstage's answer is the EntityProvider interface — a small contract any backend module can implement to become a first-class source of catalog entities, running on exactly the same machinery the built-in GitHub and GitLab providers use.

// A custom provider — beyond CBA scope, but the same interface every
// built-in provider (GitHub, GitLab, AWS) implements underneath.
import { EntityProvider, EntityProviderConnection } from '@backstage/plugin-catalog-node';
import { Entity } from '@backstage/catalog-model';

export class FleetRegistryProvider implements EntityProvider {
  private connection?: EntityProviderConnection;

  getProviderName(): string {
    return 'fleet-registry-provider';
  }

  // Called once, at startup — hand back the connection and remember it.
  async connect(connection: EntityProviderConnection): Promise {
    this.connection = connection;
  }

  // Called on a schedule (wired in the backend module, not shown here).
  async run(): Promise {
    if (!this.connection) throw new Error('Provider not connected yet');

    const services = await fetchFromInternalFleetRegistry(); // your own API
    const entities: Entity[] = services.map(toComponentEntity);

    // 'full' replaces every entity this provider previously supplied.
    // Anything missing from THIS batch is treated as deleted — a
    // decommissioned service drops out of the graph on its own, with
    // no separate cleanup step anyone has to remember to run.
    await this.connection.applyMutation({
      type: 'full',
      entities: entities.map(entity => ({
        entity,
        locationKey: `fleet-registry-provider:${entity.metadata.name}`,
      })),
    });
  }
}

The detail that matters most operationally is the type: 'full' mutation. A provider that only ever adds entities produces a catalog that grows forever and never tells the truth about what got decommissioned — exactly the "ghost service" failure mode the CBA's own troubleshooting competency names. A full mutation flips that: each run, the provider restates its entire current view, and anything it owned last run but doesn't mention this run is treated as gone. The internal system of record becomes the actual source of truth, and the catalog stays honest without a human ever running a manual cleanup.

🦋 Mira's workshop

Without writing a real provider, you can still feel the mechanism: register a repository manually with a catalog-info.yaml that has an owner pointing at a Group that doesn't exist yet in the catalog. Load the entity page and note what happens to the ownership card. Now add the Group. Refresh — no second edit to the Component, no re-registration — the ownership link resolves itself, because the edge was always there in the graph, just pointing at a node that hadn't been ingested yet. The missing-catalog-entity drill walks the closely related failure the other direction.

Where the framework model strains

☺ Like you're 10: Owning your own front desk means nobody else fixes the sticky drawer, and a family tree with a broken branch still looks fine until someone actually pulls on it.

Be honest about what the framework choice costs, because the exam's framing — "understand the framework" as one competency among nineteen — makes it sound smaller than it is. Backstage deliberately declines to make several decisions for you, and each one is a real project, not a config flag: which identity provider signs people in, which policy governs who may edit which entity (the permission framework ships as a pluggable hook, not a default policy), where TechDocs stores rendered documentation, and which search engine indexes the catalog. Adopting Backstage is honestly a dozen smaller platform decisions bundled under one name, and a team that treats "we adopted Backstage" as a finished sentence usually discovers the unfinished decisions the hard way, one incident at a time.

The graph model has its own honest limit, and it's the mirror image of the trust the reverse-edge mechanism earns in the section above: a graph is only as accurate as its ingestion. An entity whose source Location was deleted becomes an orphan — still rendered, quietly stale, its edges pointing at whatever was true the last time anything actually looked. A spec.owner naming a Group that was never ingested is a dangling edge — the ownership card looks populated right up until someone clicks through it during an incident and finds nothing there. Neither failure crashes anything or throws an error a dashboard would surface; both fail exactly the way a slightly-wrong index fails, by looking authoritative.

⚠ Trust is the actual scarce resource

A catalog that's right ninety percent of the time doesn't get treated as ninety-percent-useful — it gets treated as untrustworthy, and people quietly go back to asking in Slack who owns what, which is the actual death of a portal initiative. This is the strongest argument for automated ingestion with full mutations over hand-registered entries: not developer convenience, but the fact that a catalog nobody trusts is worse than no catalog, because it adds a step people have learned to route around.

🦆 Dot's-eye view

"Payments broke at 2am and I did the thing the portal exists for — pulled up checkout, followed dependsOn to the database, followed ownedBy to find out whose pager to page. Except the owner was a Group called payments-team-old that nobody had ingested in eight months, since the team got renamed. The edge existed. It pointed at nothing. I found the real team the old way, by asking in Slack, which is exactly what the portal was supposed to save me from doing at 2am. A graph that's slightly wrong isn't slightly useful — at 2am it's just wrong."

🎬 At Mission Control
🦊

Foxy: So the catalog basically fills itself in once you write one relation? That sounds too easy.

🦋

Mira the Butterfly: Half fills itself in, Foxy. I write owner once on the Component. The Group's ownerOf edge appears on its own. That's the whole trick — one direction by hand, the mirror computed.

🦫

Benny the Beaver: And the golden path template writes that first direction for you, before the repo even exists. I've watched a brand-new service show up already sitting under the right System.

👺

Gizmo the Gremlin: Or — hot take — just register the repo by hand and skip the whole form. Saves ninety seconds! 😈

🐢

Timmy the Turtle: And when the owner field is a typo nobody catches, Gizmo, that ninety seconds costs someone an hour at 2am chasing an edge that points at nothing. The form isn't slower — it's the only step that checks the owner is real.

🦋

Mira: Exactly. I don't gate anything — Timmy's policy layer does that, elsewhere. I just make the correct wiring the laziest possible option.

🦊

Foxy: Framework I own, graph that half-writes itself, golden path that wires the rest. I think I finally get why it's a third of the exam.

🐢 Timmy's checkpoint

1. What does it concretely cost a platform team, year over year, that "Backstage is a framework, not a product" doesn't cost the buyer of a typical SaaS tool? 2. Pick one relation pair from this page and explain which half a human writes and which half the catalog computes. 3. What does fetch:template actually pre-fill inside a new repository's catalog-info.yaml, and why does that matter more than simply generating boilerplate code? 4. Why would a platform team write a custom EntityProvider instead of relying on the built-in GitHub provider alone? 5. What does a type: 'full' mutation do that an add-only provider can't, and why does that matter for a decommissioned service? 6. Name two decisions the Backstage framework deliberately leaves for the adopting team rather than shipping a default for. 7. Why is a catalog with dangling or orphaned edges arguably worse for an on-call engineer than having no catalog at all?

Check your answers
  1. A SaaS vendor absorbs the upgrade cycle, database operation, and patching for you. Owning the Backstage framework means the platform team itself carries the TypeScript/dependency upgrade cadence (versions:bump), the PostgreSQL database and its migrations, and the decisions at every extension seam (auth, permissions, plugins) — none of which come with a vendor's SLA.
  2. Any pair is acceptable, e.g. spec.owner declared on a Component produces the ownedBy edge (written by a human); the catalog automatically computes and stores the reverse ownerOf edge on the named Group, with no second file to edit.
  3. It substitutes the form's owner and system answers directly into the new repository's catalog-info.yaml before the repo is even created — so the very first commit already declares correct graph relations. That matters because the new service is born already wired into the graph (already partOf its System, already ownedBy its Group), rather than born as an unconnected node someone has to remember to link up later.
  4. Most of an organization's real inventory — an internal fleet registry, a cloud account list, an on-call system — doesn't live in a Git organization the built-in GitHub/GitLab providers can crawl. A custom EntityProvider lets any internal system of record become a first-class, scheduled source of catalog entities using the same mechanism.
  5. A type: 'full' mutation restates a provider's entire current view on every run; anything it previously supplied but doesn't mention this run is treated as deleted and removed from the catalog automatically. An add-only provider can never do this — a decommissioned service would linger in the catalog forever as a ghost entity, exactly the failure mode the CBA's troubleshooting competency describes.
  6. Any two of: which identity/auth provider signs users in; the permission-framework policy governing who may edit which entity; where TechDocs stores rendered documentation; which search engine indexes the catalog.
  7. Because neither failure is visible as an error — an orphaned or dangling entity still renders and still looks authoritative in the UI. Trusting it costs nothing until someone actually needs it under pressure (an incident, 2am) and discovers the edge points at nothing; a catalog nobody trusts gets routed around entirely, which is worse than never having built one, because it adds a false step people have learned to skip.