Tools · Backstage

Backstage

The CBA blueprint tests Backstage as four weighted domains you can revise into a checklist. Out here, once mission-portal is actually live, it's one thing: a TypeScript monorepo your platform team forked, built, and now personally owns for as long as anyone still uses it. This page is the operational companion to that blueprint — not what the exam asks, but what actually sits in your Git organization. The two-package split between packages/app and packages/backend, and the newer backend system that assembles a whole running service out of installable modules rather than hand-wired code. The three files you'll write again and again: catalog-info.yaml describing one service, app-config.yaml configuring the portal itself, and template.yaml turning a golden path into a button anyone can press. The three ways an entity ever lands in the catalog, and which one still works past thirty services. The whole day-to-day toolchain, which is just yarn and docker. And the failure modes — a permission framework nobody turned on, a scaffolder credential with more reach than anyone remembers granting it — that show up months after launch, not during setup.

☺ Explain it like I'm 10

You know how a big secondhand shop has one till at the front, and one back room where the manager counts the money and phones the suppliers? Backstage is built exactly like that shop, on purpose. The front counter is packages/app — customers only ever see it, and it never gets to touch the cash box. The back room is packages/backend — it's the only room allowed to hold the till key, phone the suppliers, and write in the big ledger of everything the shop has ever sold. New stock doesn't appear on the ledger by magic, either: someone walks it up to the counter and hands it in themselves, the manager writes it in by hand from a list, or — the way a real busy shop actually works — a runner walks the aisles on a schedule, adds anything new, and crosses out anything that's gone. And there's one special button by the till marked "Open a new branch": press it, answer four questions, and a whole new shop — front counter, back room, first ledger entry and all — gets built for you, the exact same correct way, every single time.

🦋Your host for this topic: Mira the Butterfly — she built the storefront the CBA exam asks you to recite from memory. Here she opens the till and shows you what actually keeps the shop running.

Architecture: the two-package monorepo, and the backend you assemble from modules

☺ Like you're 10: One box is the shop window anyone can look through. The other box is the back room, and only it is ever allowed to hold a key.

npx @backstage/create-app@latest doesn't install anything — it scaffolds a Yarn workspace and hands you the keys to it. Inside sit exactly two packages that matter for this page. packages/app is a React single-page application that renders every tab, card, and form a developer clicks through; it ships to the browser, which means anything it contains is public by definition. packages/backend is a Node.js service that owns the one thing the frontend structurally never can — a real connection to a database, to GitHub, and to whatever clusters and cost systems you've wired in. The frontend talks to the backend over plain HTTP, hitting a common router at /api/<plugin> for whichever plugin's data it needs; a database password, a GitHub App's private key, a service-account token, all belong in the backend and nowhere else.

How that backend is actually assembled changed meaningfully in recent Backstage releases, and both shapes still turn up in the wild, so it's worth knowing both. The legacy backend hand-wired each plugin's router into an Express app yourself, in code only the person who originally wrote it fully understood. The new backend system replaces that with one createBackend() instance and a flat list of backend.add(import('@backstage/plugin-...')) calls — every capability the portal has, from the catalog to the scaffolder to TechDocs, is a self-contained module registered the same way, in whatever order you like, resolving its own dependencies through Backstage's own dependency-injection system rather than you threading them through by hand. A current create-app produces the new system by default; if you inherit an older portal, migrating it module by module — never in one sitting — is the sanctioned path, and the official upgrade helper linked at the foot of this page diffs your version against the tip of main to show exactly what moved.

catalog- backend catalog-backend- module-github automated ingestion techdocs- backend scaffolder- backend kubernetes- backend each added the same way — backend.add(import('@backstage/plugin-...')) createBackend() packages/backend — Node.js, listening on :7007 one process, holding every credential the modules above need the ONLY place a secret is allowed to live packages/app React SPA — :3000 renders every tab & form HTTP → /api/<plugin> PostgreSQL entities, locations, scaffolder task state owns the connection External systems GitHub · Kubernetes API Argo CD · SSO provider a scoped credential each
◆ Key idea

Notice the shape: one process, many independently-authored modules, each doing one narrow job and declaring what it needs rather than reaching out and grabbing it. It's the same idea this course keeps returning to at a different scale — Kyverno's four controllers, each with exactly one job; Argo CD's application-controller/repo-server split. Backstage draws that same line inside a single Node process instead of across separate Deployments, but the reason is identical: a narrowly-scoped module is easier to reason about, test, and eventually replace than one file that quietly does everything.

The resources you actually write

☺ Like you're 10: Three files carry almost the whole job: one card per shop item, one settings sheet for the shop itself, and — a little further down this page — one form that builds a whole new shop for you.

Backstage introduces no Kubernetes CRDs of its own. Everything it reads is plain YAML sitting in Git, which the backend parses on a schedule or on request. The two files below carry the Infrastructure and Catalog domains between them; the third, template.yaml, gets its own section further down, because the scaffolder deserves the room.

catalog-info.yaml — the card for one service, and everything that ships beside it

This file lives at the root of the repository it describes, and that placement is the whole trick: the description ships in the same pull request as the code it describes, so it can't drift the way a wiki page always eventually does. Multiple entities can live in one file, separated by ---, which is how a service, the API it exposes, and the database it depends on land together as one unit instead of three separately-maintained pages.

# catalog-info.yaml — lives at the ROOT of mission-log's own repository
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: mission-log
  title: Mission Log
  description: Records every reconciliation event across the fleet.
  tags: [go, telemetry, tier-1]
  annotations:
    backstage.io/techdocs-ref: dir:.            # Docs tab builds from ./mkdocs.yml
    backstage.io/kubernetes-id: mission-log     # Kubernetes tab matches this label
    argocd/app-name: mission-log-prod           # Argo CD tab shows sync + health
    github.com/project-slug: kubestronaut/mission-log
spec:
  type: service
  lifecycle: production
  owner: group:default/platform-team            # -> derives ownedBy / ownerOf
  system: telemetry                              # -> derives partOf / hasPart
  providesApis: [mission-log-api]
  dependsOn:
    - resource:default/mission-log-db
---
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
  name: mission-log-api
spec:
  type: openapi
  lifecycle: production
  owner: group:default/platform-team
  system: telemetry
  definition:
    $text: ./openapi.yaml                        # rendered as browsable API docs
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
  name: mission-log-db
  description: Postgres 16, provisioned alongside this service.
spec:
  type: database
  owner: group:default/platform-team
  system: telemetry
⚠ Owner and system are references, not free text

spec.owner and spec.system point at other entities by name, and Backstage never validates that the target actually exists at write time — only at read time, silently. Reference a Group that hasn't been ingested yet and you get a dangling owner reference: the ownedBy edge is recorded, but it resolves to nothing on the page. Ingest your organization — Groups and Users, from an identity provider or a checked-in org.yaml — before you ingest components, and prefer the fully-qualified group:default/platform-team over a bare platform-team. The same logic applies to system: — reference telemetry before it exists and the Component still ingests fine, it just floats with no parent until the System entity catches up.

app-config.yaml — the portal's own settings, layered and substituted

One YAML file configures the whole application: catalog rules and locations, every integration, TechDocs, and anything a plugin needs to know at startup. app-config.local.yaml overlays it for a laptop, app-config.production.yaml overlays it again for a real deploy, and ${ENV_VAR} substitution runs throughout — so the file that's readable in a pull request never has to contain an actual secret.

app:
  baseUrl: https://portal.kubestronaut.example
backend:
  baseUrl: https://portal.kubestronaut.example
  listen: { port: 7007 }
  database:
    client: pg                                    # SQLite is DEV ONLY — no persistence
    connection:
      host: ${POSTGRES_HOST}
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}

integrations:
  github:
    - host: github.com
      apps:
        - $include: github-app-credentials.yaml   # a GitHub App, never a personal token

catalog:
  rules:
    - allow: [Component, API, Resource, System, Domain, Group, User, Location, Template]
  locations:                                       # org data FIRST, so owners resolve
    - type: url
      target: https://github.com/kubestronaut/platform-config/blob/main/catalog/org.yaml
  providers:                                        # the route that scales — see below
    github:
      missionOrg:
        organization: kubestronaut
        catalogPath: /catalog-info.yaml
        filters: { branch: main, repository: '.*' }
        schedule:
          frequency: { minutes: 30 }
          timeout:   { minutes: 3 }

techdocs:
  builder: 'external'                              # built in CI, never on request — see Gotchas
  generator: { runIn: 'docker' }
  publisher:
    type: 'awsS3'
    awsS3: { bucketName: kubestronaut-techdocs }

kubernetes:                                         # powers the Kubernetes tab on each entity
  serviceLocatorMethod: { type: 'multiTenant' }
  clusterLocatorMethods:
    - type: 'config'
      clusters:
        - name: prod-eu
          url: https://k8s-prod-eu.kubestronaut.example
          authProvider: 'serviceAccount'
          serviceAccountToken: ${K8S_PROD_EU_TOKEN}  # read-only, please

☺ Like you're 10: This one file says where the shop lives, which filing cabinet it uses, which branch to scan for new stock, and which back rooms elsewhere it's allowed to peek into.

The scaffolder: turning a golden path into a button

☺ Like you're 10: One form, filled in once, that builds a whole new shop for you — front counter, back room, and its first ledger entry, all at once, correctly, every time.

A Software Template has exactly two halves, and the curriculum's own language for them is precise. spec.parameters is JSON Schema, rendered as a form — enum becomes a dropdown, pattern becomes live validation, and a handful of Backstage-specific ui:field widgets turn a plain text box into something wired straight to the catalog: OwnerPicker picks a real Group, RepoUrlPicker picks a real destination host and org, EntityPicker picks any other entity by kind. spec.steps is the ordered list of actions that actually run — server-side, in the backend, once the form is submitted — and every later step can read an earlier one's output through ${{ steps.<id>.output.<field> }}.

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: golden-path-service
  title: New Mission Service (Golden Path)
  description: Repo + CI + namespace + database, wired to the paved road.
  tags: [recommended, go]
spec:
  owner: group:default/platform-team
  type: service

  parameters:                              # -- the form a crew member fills in --
    - title: Service details
      required: [name, owner]
      properties:
        name:
          title: Service name
          type: string
          pattern: '^[a-z][a-z0-9-]{2,29}$'
        owner:
          title: Owning team
          type: string
          ui:field: OwnerPicker            # picks a Group from the catalog
          ui:options: { catalogFilter: { kind: Group } }
        size:
          title: Database size
          type: string
          default: small
          enum: [small, medium, large]     # renders as a dropdown
    - title: Repository
      required: [repoUrl]
      properties:
        repoUrl:
          title: Location
          type: string
          ui:field: RepoUrlPicker
          ui:options: { allowedHosts: [github.com], allowedOwners: [kubestronaut] }

  steps:                                   # -- what runs on submit, in order --
    - id: fetch
      name: Render the skeleton
      action: fetch:template
      input:
        url: ./skeleton                    # the directory next to this file
        values:                            # substituted into ${{ values.* }}
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}
          size: ${{ parameters.size }}

    - id: publish
      name: Create the repository
      action: publish:github
      input:
        repoUrl: ${{ parameters.repoUrl }}
        defaultBranch: main
        repoVisibility: internal
        access: kubestronaut/platform-team  # admin collaborator: an org/team SLUG

    - id: register
      name: Register in the catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml

  output:                                  # links shown on the success page
    links:
      - title: Repository
        url: ${{ steps.publish.output.remoteUrl }}
      - title: Open in catalog
        icon: catalog
        entityRef: ${{ steps.register.output.entityRef }}

Beside that file sits a skeleton directory — an ordinary project tree with ${{ values.* }} placeholders sprinkled through it: skeleton/catalog-info.yaml already reads name: ${{ values.name }}, skeleton/.github/workflows/ci.yaml holds your standard pipeline, skeleton/db-claim.yaml a database claim sized by size. fetch:template renders every file and drops the result straight into the new repository, so the service is born already catalogued, already built by CI, already on the paved road — and nobody typed any of that in by hand.

spec.parameters spec.steps — run server-side, in the order written Parameters JSON Schema → a form OwnerPicker · RepoUrlPicker fetch:template renders skeleton/ values.* substituted publish:github creates the repo, grants team access catalog:register creates a Location, ingests catalog-info.yaml New repository CI + Dockerfile + catalog-info.yaml, already inside New Component entity live in the catalog, owner already set One "Create" click — nobody hand-wires ownership, CI, or the catalog entry again.
⚠ Two ways a working template quietly stops working

Omit Template from catalog.rules.allow and every template you've published simply refuses to appear on the Create page — no error anywhere, just an empty list, because the catalog never ingested the Template kind at all. And because publish:github genuinely creates repositories, grants team access, and opens pull requests, the credential behind it is a real production capability: use a scoped GitHub App rather than a personal access token tied to one engineer, keep the templates repository restricted to platform-team merges, and inject the credential at runtime rather than committing it beside the templates it drives.

🦋 Mira's workshop · 30 min

Copy golden-path-service above into a repo of your own, next to a skeleton/ folder containing nothing but a README.md and a catalog-info.yaml with name: ${{ values.name }}. Add its URL under catalog.locations with rules: [ { allow: [Template] } ], restart yarn dev, and find it on the Create page. Fill in the form, submit, and watch three things happen in order: a real repository appears under your account, it contains a rendered catalog-info.yaml with your chosen name already substituted in, and — because the last step ran — a brand-new Component shows up in the catalog with no manual registration at all. Now delete the register step and run it again: the repo still appears, but nothing shows up in the catalog, which is the fastest way to feel why catalog:register is its own separate step rather than something fetch:template does for free.

Catalog ingestion: three routes onto the graph

☺ Like you're 10: Stock gets onto the ledger three ways: someone hands it to the till themselves, the manager writes it in from a list, or a runner walks the aisles on a schedule and keeps the ledger honest without being asked.

Three mechanisms exist, and the curriculum names two of them as their own competencies because they behave differently enough in production to trip people up, not just on paper. Manual registration is a human pasting a URL into "Register existing component," which creates exactly one Location — fine for one repo, a genuine liability at scale, because nothing removes that Location when the repo it points to is deleted or renamed. Static locations are URLs listed directly under catalog.locations in app-config.yaml — the right shape for things that rarely change and matter a lot, like the org.yaml carrying your Groups and Users, or the template locations from the section above. Entity providers are the only mechanism built to scale past a handful of services: catalog.providers.github — with equivalents for GitLab, Bitbucket, Azure DevOps, and org-data providers like LDAP or Microsoft Graph — crawls a whole organization on a schedule, ingesting every repository whose default branch carries a catalog-info.yaml, and, critically, removing the entity again the moment that file disappears. A Location, by contrast, just goes stale and sits there.

You never write the reverse of a relationship yourself — the catalog derives it. Set spec.owner: platform-team on a Component and Backstage creates the ownedBy edge and the reciprocal ownerOf edge back on the Group, automatically; the same pairing covers partOf/hasPart, providesApi/apiProvidedBy, and dependsOn/dependencyOf. The Backstage Portal Model covers why that graph shape matters more than it looks like it should; this page only needs you to know it's derived, not hand-maintained, before the troubleshooting below makes sense.

Troubleshooting entity ingestion is its own named competency for a reason — three failure modes account for nearly everything that goes wrong in practice. A malformed or unreachable catalog-info.yaml surfaces as a processing error visible on the entity's own page, and the entity simply stops refreshing until it's fixed. An orphan is an entity whose parent Location vanished — Backstage tags it with backstage.io/orphan, so you can find every one of them in a single query. And a stale entity lingers when the repository itself is gone but the Location that registered it never was — the fix there is deleting the Location, not the entity, because deleting the entity directly just gets it re-created on the next scan.

# What does the catalog actually think exists, right now?
curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/catalog/entities/by-query?filter=kind=component,spec.lifecycle=production" \
  | jq -r '.items[] | "\(.metadata.name)\t\(.spec.owner)"'

# One entity in full — check managed-by-location and any orphan annotation first
curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/catalog/entities/by-name/component/default/mission-log" \
  | jq '.metadata.annotations'

# Every orphan in one query — parents that vanished, entities left behind
curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/catalog/entities/by-query?filter=metadata.annotations.backstage.io/orphan=true" \
  | jq -r '.items[].metadata.name'

# Ghost service still on the page? List locations, then delete the one that created it
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/catalog/locations" | jq .
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$BASE/api/catalog/locations/<id>"

Day-to-day commands

☺ Like you're 10: No special control panel — the same handful of commands any website's own developers already type all day.

There's no backstage CLI that talks to a running server the way argocd or kyverno do — this is a Node application, so the toolchain is Yarn, npx, and its own HTTP API.

# Scaffold a brand-new portal — a monorepo you now own, not an install
npx @backstage/create-app@latest --path mission-portal

cd mission-portal
yarn install                      # resolves the whole Yarn workspace
yarn dev                          # app on :3000, backend on :7007, both hot-reloading

# Run one half at a time — useful when only the backend is misbehaving
yarn workspace app start
yarn workspace backend start

# Add a plugin — most ship as a pair, frontend AND its backend counterpart
yarn workspace app add @backstage/plugin-kubernetes
yarn workspace backend add @backstage/plugin-kubernetes-backend

# Scaffold your OWN plugin inside this monorepo
yarn new

# Type-check, lint, and test the whole workspace before you trust it
yarn tsc && yarn lint:all && yarn test:all

# Production build, then a container image from the BACKEND's own Dockerfile
yarn build:all
yarn build:backend --config ../../app-config.yaml
docker image build . -f packages/backend/Dockerfile --tag kubestronaut/mission-portal:2026.08.1

# Keep every @backstage/* package on one release line
npx @backstage/cli versions:bump

# Preview TechDocs locally, then build + publish the way CI does (builder: 'external')
npx @techdocs/cli serve
npx @techdocs/cli generate --source-dir . --output-dir ./site
npx @techdocs/cli publish --publisher-type awsS3 --storage-name kubestronaut-techdocs \
  --entity default/component/mission-log

Two details there earn marks on their own if this shows up on the CBA paper. The image is built from packages/backend/Dockerfile, not two separate Dockerfiles, because in a production build the compiled frontend is served by the backend — one image, one process. And versions:bump is the whole upgrade story: Backstage releases often, its packages must move together, and a portal left untouched for a year is a genuinely painful thing to bring forward again.

Gotchas and failure modes

☺ Like you're 10: Most surprises come from a default nobody changed, a credential doing more than anyone remembers granting it, or forgetting that the shop only ever updates its own ledger — never what's actually running.

You adopted an application — upgrades are now your job, forever

This is the gotcha that sinks adoptions quietly, months after launch rather than during it. Backstage ships as source you fork and own: a TypeScript monorepo with a frontend, a backend, a Dockerfile, and your own plugin wiring. You own its build pipeline, its Postgres, its SSO, its uptime, its dependency CVEs, and — the big one — its upgrades. The project has moved through significant architectural shifts, the new backend system among them, so a portal left alone for a year is genuinely painful to bring forward. Name an owner before launch, run versions:bump on a cadence, and upgrade in small hops using the official upgrade helper rather than one heroic leap eighteen months late.

Auth and permissions are opt-in, and the generated defaults are not production

⚠ A fresh create-app is wide open by default

Two defaults bite hard, and neither is loud about it. The generated app uses a guest sign-in provider and an in-memory SQLite database — fine for yarn dev, catastrophic in production, where you need real SSO and Postgres. And Backstage's permission framework is opt-in: until you write a policy, any authenticated user who reaches the portal can execute any template — which, given the scaffolder's real credentials, means anyone who can log in can create repositories and grant team access in your org. Turn on SSO, enable the permission backend, and gate expensive templates before you announce the portal company-wide. That's a governance requirement, not a nice-to-have you get to later.

Providers at scale can exhaust your own rate limit

An entity provider crawling thousands of repositories at a tight schedule.frequency will quietly exhaust your source-control API's rate limit long before anyone thinks to check it — widen the interval, and prefer a GitHub App's higher quota over a personal access token for exactly this reason, not only for the credential-scoping argument above.

A blank tab is almost always a missing annotation, not a broken plugin

An empty Kubernetes or Argo CD tab on an otherwise healthy entity is, nine times out of ten, a mismatch: backstage.io/kubernetes-id doesn't match the real pod labels, or argocd/app-name doesn't match the actual Argo CD Application name. Check the annotation against the live resource before you go looking for a bug in the plugin itself — the plugin is almost always fine, the string it was given almost always isn't.

🦋 Mira's-eye view

"A team once paged me convinced the Kubernetes plugin was broken — the tab had shown nothing for a week. It wasn't the plugin. Someone had renamed a Deployment's app.kubernetes.io/name label during a cleanup and never touched backstage.io/kubernetes-id on the entity to match. The portal wasn't lying. It was faithfully reporting zero matching pods for the label it had actually been given — which is a boring, correct answer to a slightly wrong question nobody had asked it in a while."

Backstage vs. the alternatives

☺ Like you're 10: Backstage is the build-it-yourself shop, from a kit. Some companies buy a finished one instead, and small shops sometimes don't need a storefront at all yet.

Ask first whether you need a portal at all. With twenty services and one team, a good README and a make new-service script beat a Node monorepo you have to nurse — adopting a portal to create discovery demand, rather than to satisfy demand that already exists, is how portals become expensive screensavers nobody opens.

OptionShapeStrengthsChoose it when
Backstage (self-hosted)Open framework you fork, extend, and runNo license cost, huge plugin ecosystem, unlimited customization, CNCF-governedYou have engineers to own a TypeScript app long-term, and want the portal shaped exactly like your platform
Backstage distributions (Red Hat Developer Hub, Spotify Portal, Roadie)Backstage, packaged and supportedSame APIs and templates; upgrades and hardening become someone else's jobYou want Backstage's model without personally owning its release lifecycle
Closed-source portals (Port, Cortex, OpsLevel, Harness IDP)SaaS, configuration-drivenFast to value, opinionated catalogs and scorecards out of the box, no app to maintainTime-to-value matters more than customization, and a per-seat bill is acceptable
CLI + templates (cookiecutter, a repo template)No portal at all — a generator and a conventionNear-zero maintenance; developers who already live in a terminal don't notice the absenceSmall org, or a culture that genuinely prefers a CLI to a web form
Git + CODEOWNERS + a wikiThe status quoFree; already exists; nothing new to learnUnder roughly thirty services — but expect ownership rot as you grow past that

Two habits de-risk the choice either way. Keep the source of truth in Git — entity descriptions beside the code, templates in a reviewable repository — so migrating portals later means re-pointing an ingester, not re-entering a thousand records by hand. And remember the portal is the thinnest layer in the whole stack: the value lives in the platform behind it, which is why this course's own certifications treat Backstage as its own narrow, single-product exam — see CBA — the exam — rather than folding it into the broader GitOps or mesh material. For the fuller build-versus-buy argument from a platform-engineering angle rather than an exam angle, Platform Engineering's own Backstage page and Platform as a Product go further than this page needs to.

🎬 At Mission Control
🦊

Foxy: Leadership saw a Backstage demo. They want mission-portal live by Friday. It's a website, right? How hard can it be?

🦋

Mira: It's a TypeScript monorepo we fork, own, and upgrade forever. Friday gets you an empty catalog — and an empty catalog is worse than none. People try it once, find nothing, and never come back.

🐿️

Nutty: I counted while Mira was talking — thirteen of the nineteen CBA competencies are code or a build step. This isn't a "point it at a cluster" tool.

👺

Gizmo: Faster fix: skip the GitHub App, drop my own personal token into the scaffolder config, and turn off that permission-policy nonsense too. Ship it tonight! 🤑

🐢

Timmy: Two mistakes in one sentence, Gizmo. Your personal token dies the day you leave the team, and it's tied to you, not the platform. And with no permission policy, anyone who can log in can run that scaffolder and create repos under your name.

🦫

Benny: A scoped GitHub App and one permission policy is maybe a day of work. I'd rather spend Friday on that than explaining an incident on Monday.

🦋

Mira: Agreed. Seed the catalog with the fifty services people actually search for, ship one template that genuinely works, and measure who opens it. That's a launch. A blank shop with the doors open isn't.

🐢 Timmy's checkpoint

1. What are the two packages a create-app produces, and which one is allowed to hold a secret? 2. In the new backend system, how is a capability like the catalog or the scaffolder added to a running backend? 3. In catalog-info.yaml, what's the difference between a declared field like spec.owner and a derived relation like ownedBy? 4. Name the three ways an entity gets into the catalog, and which one scales past a handful of services. 5. In a Software Template, what's the difference between parameters and steps, and what does the skeleton directory contain? 6. Your Kubernetes tab is blank on an otherwise healthy entity — what's the first thing to check, and what usually isn't the problem? 7. Name two things a fresh create-app leaves wide open by default, and what closes each one.

Check your answers
  1. packages/app (the React frontend) and packages/backend (the Node.js backend). Only the backend may hold a secret — anything shipped to packages/app reaches the browser and is public by definition.
  2. It's registered with backend.add(import('@backstage/plugin-...')) against one createBackend() instance — each capability is a self-contained module, resolving its own dependencies, rather than hand-wired into an Express app the way the legacy backend required.
  3. A declared field like spec.owner: group:default/platform-team is something you write yourself in the YAML. A derived relation like ownedBy (and its reciprocal ownerOf on the Group) is something Backstage computes automatically from that field — you never write the reverse edge by hand.
  4. Manual registration (a human registers a URL, creating a Location), static locations (URLs under catalog.locations in config), and entity providers that crawl an organization on a schedule. Only providers scale — and they also remove entities when the underlying file disappears, which locations never do on their own.
  5. parameters is JSON Schema rendered as the form the user fills in; steps is the ordered list of server-side actions run on submit (fetch:template, publish:github, catalog:register…). The skeleton directory is the project tree the template renders — README, CI workflow, catalog-info.yaml — with ${{ values.* }} placeholders substituted in.
  6. Check whether backstage.io/kubernetes-id (or the label selector variant) on the entity actually matches the live resource's labels — a mismatched string, not a broken plugin, causes nearly every blank tab of this kind.
  7. Any two of: a guest sign-in provider (closed by wiring real SSO), an in-memory SQLite database (closed by configuring PostgreSQL), and an opt-in permission framework with no policy written (closed by enabling the permission backend and writing one before launch).