Drill — Debug a Missing Catalog Entity
A team ships a new service, writes a correct catalog-info.yaml for it, pushes to main, and waits. The portal never shows it. No red banner, no processing error, no orphan tag — nothing to read, because nothing ever tried and failed. That silence is the entire drill: most catalog bugs leave a trail, and this one deliberately doesn't. You'll stand up a real Backstage instance with a GitHub entity provider, ship one repo that ingests cleanly as your control, ship a second that vanishes into nothing, and use the catalog's own REST API to rule out the two failure modes that do leave evidence before landing on the one that doesn't. Fully self-contained — a throwaway local portal and two throwaway GitHub repos, no dependency on the five-part capstone. Budget 25-40 minutes before you open the walkthrough below.
Imagine a school where a new kid tries to join the lunchtime chess club. If his form is filled out wrong, the teacher hands it back with red pen all over it — an error, something to fix. If he used to be in the club and then moved to a different school, his name gets crossed off the roster with a note saying "moved away" — an orphan, something explained. But now imagine a third thing: the sign-up desk was only ever told to accept forms from kids whose last name starts with a letter in the first half of the alphabet, and nobody remembered to tell the new kid that rule, because it's written on a sticky note stuck to the underside of the desk. His form never even reaches the teacher's inbox. No red pen, no roster note — the desk just quietly never picked it up. That's today's bug, and the sticky note is exactly what you're hunting for.
You need Node.js 20 or 22, Yarn, git, a GitHub account, the GitHub CLI (gh, authenticated with gh auth login), and curl + jq for the API work. Everything else — the Backstage app, both repos — is brand-new and throwaway; delete it all when you're done (gh repo delete --yes on each, rm -rf the local app). Backstage's CLI and its GitHub provider's config shape move release to release — if a command below errors, check npx @backstage/create-app@latest --help or the current GitHub discovery docs and adapt; diagnosing tooling that changed under you is its own small rep of today's skill.
Why this failure mode is worth its own drill
☺ Like you're 10: Most bugs leave a mess behind. This one leaves nothing at all — and "nothing" is a clue you have to learn to read.
The Backstage tools page on this site names three ways an entity can fail to show up cleanly: a malformed or unreachable catalog-info.yaml surfaces a processing error on the entity itself; an orphan is an entity whose parent Location vanished, tagged backstage.io/orphan so it's easy to find; and a stale entity lingers when its source repo is gone but nobody deleted the registration. All three assume an entity exists to be examined. Today's bug is a fourth shape entirely, upstream of all three: an entity provider filter that excludes a repository before the provider ever tries to fetch, let alone parse, its catalog-info.yaml. Nothing is malformed. Nothing is orphaned. Nothing was ever attempted. The catalog has zero opinion about a repo it was never told to look at — and that's precisely why you have to prove the absence of the other two causes before this one is even a reasonable suspect.
Build the scratch portal
☺ Like you're 10: One small Backstage app, one GitHub provider pointed at your own account, configured with a rule nobody updated.
Create a fresh Backstage app — this is the throwaway front desk for today only:
npx @backstage/create-app@latest --path catalog-drill
cd catalog-drillConfigure a GitHub entity provider in app-config.yaml. Two years ago this org put every service repo behind a svc- prefix and wrote that convention straight into the filter — reasonable at the time, and exactly the kind of rule that outlives the reason for it:
# app-config.yaml — add under the existing `catalog:` key
catalog:
providers:
github:
driveOrg:
organization: ${GITHUB_ORG}
catalogPath: /catalog-info.yaml
filters:
branch: main
repository: '^svc-.*$' # written when every service repo had this prefix
schedule:
frequency: { seconds: 30 }
timeout: { minutes: 1 }
initialDelay: { seconds: 5 }Give the provider read access with a token — a scoped fine-grained GitHub App is what a production portal should use (see the credential warning on the Backstage tools page), but a personal token is fine for a 30-minute throwaway:
export GITHUB_ORG=$(gh api user -q .login)
export GITHUB_TOKEN=$(gh auth token)# app-config.yaml — integrations block
integrations:
github:
- host: github.com
token: ${GITHUB_TOKEN}Ship a control, then ship the one that vanishes
☺ Like you're 10: One repo that follows the old rule, and proves the desk works at all. One repo that doesn't, and disappears.
First, the control — a repo whose name still matches the old prefix, so you have proof the provider works before you go hunting for why something else doesn't:
mkdir svc-notifications && cd svc-notifications && git init -b main
cat > catalog-info.yaml <<'EOF'
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: notifications
description: Sends the emails and pushes nobody reads until something breaks.
spec:
type: service
lifecycle: production
owner: group:default/platform-team
EOF
git add -A && git commit -m "chore: catalog-info.yaml"
gh repo create svc-notifications --public --source=. --remote=origin --push
cd ..Now the one that actually matters — a brand-new service, correctly described, that simply doesn't happen to carry the old prefix, because nobody told whoever named it that the prefix still meant anything:
mkdir payments-ledger && cd payments-ledger && git init -b main
cat > catalog-info.yaml <<'EOF'
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payments-ledger
description: The append-only ledger every payment event gets written to.
spec:
type: service
lifecycle: production
owner: group:default/payments-team
EOF
git add -A && git commit -m "chore: catalog-info.yaml"
gh repo create payments-ledger --public --source=. --remote=origin --push
cd ..Start the portal and give the provider one full cycle:
cd catalog-drill
yarn install
yarn dev >/tmp/catalog-drill.log 2>&1 &
sleep 45 # initialDelay (5s) + one full frequency tick (30s), with room to spareOpen http://localhost:3000/catalog. notifications is there. payments-ledger is not — not greyed out, not marked with a warning icon, not present in any filtered view. It simply isn't in the list, exactly as if the repo didn't exist.
Rule out the loud failure first
☺ Like you're 10: Ask the front desk directly: do you have any record of this kid at all?
If payments-ledger's catalog-info.yaml were malformed, the entity would still exist as far as the catalog's concerned — just carrying a processing error. Ask directly, by name, and let the HTTP status answer the question before you read a single byte of the body:
curl -s -o /tmp/entity.json -w '%{http_code}\n' \
-H "Authorization: Bearer $(yarn --silent backstage-cli config:print --format json 2>/dev/null | jq -r '.backend.auth.externalAccess[0].options.token // empty')" \
"http://localhost:7007/api/catalog/entities/by-name/component/default/payments-ledger"If your local instance has no auth configured (the default for create-app in dev mode), drop the header entirely — dev mode's catalog API is open on localhost:
curl -s -o /tmp/entity.json -w 'HTTP %{http_code}\n' \
"http://localhost:7007/api/catalog/entities/by-name/component/default/payments-ledger"
cat /tmp/entity.json | jq .HTTP 404
{
"error": {
"name": "NotFoundError",
"message": "component:default/payments-ledger not found"
}
}404, not 200-with-errors. That's the whole first ruling: the catalog has no record of this entity at all, good or bad. A malformed file that the provider had actually tried to fetch and parse would leave something behind — at minimum a processing error tied to the source location. A flat 404 means the parse step was never reached, which means the bug lives somewhere earlier than the file itself.
Rule out an orphan next
☺ Like you're 10: An orphan is a kid whose enrollment record still exists with "moved away" written on it. This kid was never enrolled — there's no record to have gone stale.
An orphaned entity is, by definition, one that used to ingest successfully and then lost the Location that produced it — Backstage tags the survivor backstage.io/orphan: "true" so it's easy to find every one of them in a single query:
curl -s "http://localhost:7007/api/catalog/entities?filter=metadata.annotations.backstage.io/orphan=true" \
| jq -r '.[].metadata.name'# (empty — no output at all)Empty result, and it has to be: payments-ledger already failed the 404 check above, so there is no prior entity for it to have orphaned from. Orphan status is a property of something that once existed; you've just proven nothing here ever did. Two of the site's three named failure modes are now formally eliminated, by evidence, not by assumption — which is exactly the discipline the CBA blueprint means by "troubleshooting entity ingestion" as its own competency, distinct from just knowing what the catalog is.
Every entity-ingestion bug sits at one of two stages, and the API tells you which before you've read a line of application config: a 200 with an error condition or an orphan annotation means the provider reached the file and something went wrong after that; a flat 404 with a control entity ingesting fine nearby means the provider never reached the file at all. Those are different bugs in different files — one lives in catalog-info.yaml itself, the other lives in whatever decides which repositories the provider is even allowed to look at.
Find the sticky note under the desk
☺ Like you're 10: Read the exact rule the desk is using to decide who it even looks at.
With the file and the location both ruled out, the only remaining stage is the one that runs before either — the provider's own filters block. Check the dev server's own log first; the GitHub provider reports what it discovered each cycle:
grep -i "github-provider\|driveOrg" /tmp/catalog-drill.log | tail -5[catalog] github-provider:driveOrg — discovered 2 repositories in org, 1 matched filters
[catalog] github-provider:driveOrg — applying 1 entity from svc-notifications2 discovered, 1 matched. The provider saw payments-ledger and correctly, deliberately, did not select it — this isn't a crash or a timeout, it's the filter working exactly as configured. Confirm the regex itself, on the actual repo names, before you touch the config file:
node -e "console.log(/^svc-.*$/.test('svc-notifications'))" # true
node -e "console.log(/^svc-.*$/.test('payments-ledger'))" # falseThere it is, provably: filters.repository: '^svc-.*$' in app-config.yaml anchors on a naming convention that payments-ledger never had reason to follow. Nobody broke anything — a rule written for a naming scheme that later changed just kept quietly doing its job on repos that no longer matched it.
The tempting minimum fix is repository: '.*' — match everything, problem gone. That works, but it also means any repo in the org with a catalog-info.yaml at the right path silently joins your catalog the moment someone adds one, including a fork, a fork of a fork, or an abandoned prototype nobody meant to publish. A real platform team usually gates on something more intentional than a name — a required GitHub topic like backstage-managed, or an org-level list of approved repos — and pairs it with catalog.rules as a second gate on which kinds get ingested at all. For this drill, '.*' is fine; in production, treat the filter as a security boundary, not a formality.
Fix it, and prove it holds
☺ Like you're 10: Cross out the old rule, save the file, and watch the desk notice the kid it skipped over.
Widen the filter and save — Backstage's dev server watches app-config.yaml and restarts the backend on its own, which re-runs the provider's initialDelay discovery pass immediately:
# app-config.yaml — the fix
filters:
branch: main
repository: '.*' # was: '^svc-.*$' — the prefix convention is deadsleep 10 # backend restart + initialDelay
grep -i "driveOrg" /tmp/catalog-drill.log | tail -3[catalog] github-provider:driveOrg — discovered 2 repositories in org, 2 matched filters
[catalog] github-provider:driveOrg — applying 1 entity from payments-ledgerConfirm the same way you diagnosed — through the API, not just the UI:
curl -s -o /tmp/entity.json -w 'HTTP %{http_code}\n' \
"http://localhost:7007/api/catalog/entities/by-name/component/default/payments-ledger"
jq '.metadata.annotations."backstage.io/managed-by-location"' /tmp/entity.jsonHTTP 200
"url:https://github.com/<you>/payments-ledger/blob/main/catalog-info.yaml"Done when: the by-name query returns HTTP 200 with a real entity body, backstage.io/managed-by-location points at the GitHub provider's URL (not a manually-created Location you added yourself), and http://localhost:3000/catalog lists payments-ledger right alongside notifications with no page reload needed.
The transferable habit
☺ Like you're 10: When there's nothing to read, that absence is itself the clue — go find the rule that decided not to look.
The instinct most engineers bring to "it's not showing up" is to reread the file that's supposedly wrong — and today's file was never wrong. The move that actually finds this bug is asking the catalog directly, by name, what it knows, and treating a flat 404 as meaningfully different from a 200-with-a-warning. Once you've eliminated "the file failed" and "the location vanished," the only place left to look is whatever decided the file was worth reading in the first place — a provider filter, an allow-list under catalog.rules, a branch requirement, an ownership check on the scaffolder side. That generalizes well past Backstage: a CI job that silently never runs because a path filter didn't match, an alert that never fires because a label selector missed by one character, a webhook that never delivers because an IP allow-list was never updated. All of the same shape — no error, because nothing was ever attempted, and the fix lives in the gate, not in whatever the gate was guarding.
"People assume every catalog bug leaves a paper trail, because most of them do. The ones that don't are the ones I actually worry about, because a team can go months not knowing a service was never onboarded — nobody's paged, nothing's red, the service just quietly isn't anywhere anyone would think to look for it. The fix is always to ask the API directly what it knows, by name, before you trust what the UI happened to render.”
yarn dev starts cleanly and app-config.yaml has the catalog.providers.github block with the ^svc-.*$ filter in place.localhost:3000/catalog but payments-ledger does not.entities/by-name curl returns a flat 404, not a 200 carrying a processing error.backstage.io/orphan=true query returns nothing, and you can say in one sentence why an orphan can't apply here.github-provider log line naming the match count.node -e checks show true for svc-notifications and false for payments-ledger.2 discovered, 2 matched after the backend restarts.managed-by-location set, and the entity appears in the catalog page.Foxy: I checked catalog-info.yaml four times. It's fine. It's completely fine. Why is nothing happening?!
Mira the Butterfly: Because the file was never the problem, Foxy. Ask the API by name — a 404 means the provider never even opened it.
Gizmo the Gremlin: Or just click "Register existing component" and paste the URL in yourself. One click, entity appears, drill's over. 😈
Timmy the Turtle: That papers over it for one entity, Gizmo. The next new repo hits the exact same dead filter and vanishes the exact same way — and nobody notices until someone goes looking for a service that was never there.
Mira the Butterfly: Manual registration is a patch, not a fix. The fix lives in the rule that decides what the provider even looks at.
Foxy: So a missing service isn't always a broken service. Sometimes it's a service nobody ever told the front desk to expect.
Mira the Butterfly: That's the whole drill in one sentence.
1. Why does a flat 404 from entities/by-name rule out a malformed catalog-info.yaml as the cause? 2. Why can't an entity provider filter mismatch ever produce an orphan-tagged entity? 3. What did the discovery log's "2 discovered, 1 matched" line tell you that the catalog REST API alone couldn't? 4. Why is repository: '.*' a reasonable fix for this drill but a risky default for a real production provider? 5. Name the general habit this drill is really teaching, in one sentence that has nothing to do with Backstage specifically.
Check your answers
- A malformed file that the provider actually fetched and tried to parse would still leave an entity record behind — carrying a processing error, returned as a 200. A flat 404 means the catalog has zero record of the entity at all, which means the fetch-and-parse step was never reached in the first place.
- Orphan status only ever applies to an entity that previously ingested successfully and then lost its source
Location. A filter mismatch means the entity was never ingested to begin with, so there is nothing for it to have been orphaned from — the two failure modes are mutually exclusive by definition. - The catalog REST API can only tell you what the catalog itself currently knows — and here, that's nothing. The provider's own discovery log is the only place that records what it considered and rejected, which is exactly the evidence that points at the filter rather than the file.
- For a two-repo throwaway drill there's no real risk in matching everything. In a real org, an unrestricted repository filter means any repo with a
catalog-info.yamlat the right path — a fork, a stale prototype, someone's personal clone — joins the production catalog automatically the moment the file exists, which is why real teams gate on something more deliberate, like a required topic or an approved-repo list, alongsidecatalog.rules. - When something is silently missing with no error anywhere, don't keep re-checking the thing that's absent — go find the gate that decides what gets looked at, because a filter that quietly excludes something correct produces no evidence at the layer where you'd normally go looking for a bug.
Fixed and holding? Good — that's the whole drill. For the concepts underneath it, see The Backstage Portal Model and Backstage, whose own troubleshooting section is where the three named failure modes this drill built on came from; for the certification this exact competency sits on, see CBA. If your interest is the platform-engineering side of self-service portals rather than Backstage specifically, Self-service & golden paths and the CBA on Platform Engineering's own certifications hub cover the wider ground. Ready for a different single skill? Try Drill — Diagnose a Stuck Argo CD Sync, or go build the full loop in Build Your Cert Tracker — Start Here.