Hands-On Labs · The Capstone · Part 5 of 5

Capstone Part 5 — The Portal

This is Part 5 — the last of five — in the capstone running through this whole course: one continuously evolving service, kubestronaut-tracker, carried from a bare git repo through a self-healing Argo CD deployment in Part 1, a canary that promotes or aborts itself in Part 2, an Istio mesh with Kyverno enforcing admission in Part 3, and a Prometheus/Grafana/OpenTelemetry stack that can name its own golden signals and traces in Part 4. Everything above works, and none of it is easy to find. This part closes the loop: a minimal Backstage instance, running on your own laptop, that lists kubestronaut-tracker as a real catalog entity — correct ownership, a live Kubernetes tab, a live Argo CD tab, and rendered TechDocs — all on one page a teammate could open without ever radioing you directly. Five parts, one loop, closed.

☺ Explain it like I'm 10

Every system you built in this capstone is like a system inside a real spacecraft — a life-support loop, a guidance computer, a comms array — each one built and proven separately in its own bay. But someone arriving fresh to the mission doesn't want to hunt through five separate bays to find out who's flying it, where the manual is, or whether the guidance computer is currently green or red. This part builds that one flight-ops board: a single screen listing the ship, who owns it, its instruction manual, and live lights for "is Git in sync," "is Kubernetes healthy," and "is the canary rollout green" — all in one place, so someone who has never touched this mission before can find out everything about it without asking you first.

🦋Your host for this part: Mira the Butterfly — she turns systems other specialists built separately into one thing a stranger could actually operate. Every earlier host in this capstone built one bay; Mira wires the board that shows all of them at once.

What you're building on, and what this part adds

☺ Like you're 10: Four bays are already built and working. This part doesn't touch any of them — it just adds the one screen that shows all four at once.

This page assumes Part 1 through Part 4 are all behind you, all inside the same kubestronaut-dev kind cluster: an Argo CD Application named kubestronaut-tracker in the platform namespace, synced and healthy with prune and selfHeal both true; an Argo Rollouts canary object in tracker that promotes a good build through 20% → 50% → 100% unattended and aborts a bad one automatically; Istio with a STRICT PeerAuthentication on tracker and Kyverno running in enforce mode, rejecting :latest tags and root containers at admission; and a kube-prometheus-stack plus an OpenTelemetry Collector in platform, with a Grafana dashboard showing all four golden signals, a PrometheusRule that's fired on an injected fault, and one trace that's shown a request's full path across the mesh. If any of that isn't true yet, this part will still run, but its "done when" line — a teammate reaching a working dashboard from the catalog — will be pointing at nothing. Go make it true first.

ThingName / shapeIntroduced
Applicationkubestronaut-trackerGET /health, /certs, POST /certs/:id/complete, GET /progress, backed by single-replica PostgresPart 1
GitOps loopArgo CD Application in platform, watching deploy/argocd, self-healingPart 1
Progressive deliveryArgo Rollouts canary, 20/50/100 with an AnalysisTemplate against PrometheusPart 2
Mesh & policyIstio strict mTLS on tracker · Kyverno enforce modePart 3
Observabilitykube-prometheus-stack + OTel Collector in platform, 2 golden-signal panels, 1 alert, traces across the meshPart 4
Backstage instancekubestronaut-portal — a separate create-app monorepo, run locally with yarn devPart 5 — this page
Catalog entitiesComponent kubestronaut-tracker + Resource kubestronaut-tracker-db + Group kubestronaut-crewPart 5 — this page
Kubernetes tabbackstage.io/kubernetes-id: kubestronaut-tracker label + a read-only backstage-viewer ServiceAccountPart 5 — this page
Argo CD tabargocd/app-name: kubestronaut-tracker annotation + the RoadieHQ Argo CD pluginPart 5 — this page
TechDocsportal/mkdocs.yml, portal/docs/, backstage.io/techdocs-ref: dir:portalPart 5 — this page

Everything below assumes the tool-neutral catalog model from The Backstage Portal Model — the catalog as a graph, declared fields producing derived reverse edges, golden paths as an on-ramp onto that graph — and goes deep on one tool already covered on its own page: Backstage, for the two-package monorepo, catalog-info.yaml, and the three ingestion routes. Read that first if packages/app vs. packages/backend, or the difference between a Location and an entity provider, are unfamiliar — this page puts them to work on your own running cluster rather than re-explaining them.

Scaffolding a Backstage instance for the whole capstone

☺ Like you're 10: This board isn't part of the ship — it's a separate console you build once, then point at whatever ships you want it to watch.

kubestronaut-portal is deliberately its own project, not a folder inside kubestronaut-tracker's own repo — a developer portal watches many services, and this capstone only happens to have one. Scaffold it the same way the Backstage page does, next to (not inside) your kubestronaut-tracker checkout:

npx @backstage/create-app@latest --path kubestronaut-portal
cd kubestronaut-portal
yarn install
yarn dev   # app on :3000, backend on :7007, both hot-reloading

A fresh create-app ships with a guest sign-in provider and an in-memory SQLite database — both explicitly dev-only defaults, per Backstage's own gotchas section. For this capstone that's the right call, not a shortcut to feel guilty about: everything here is local and throwaway by design, same as the rest of the track. If you carry this pattern into a real team's portal, that's exactly the point in the journey where real SSO, real Postgres, and a written permission policy stop being optional — this page doesn't build any of that, on purpose, to keep Part 5 the size of one afternoon.

Ingesting the org first: a Group before a Component can point at it

☺ Like you're 10: Write down who the crew is before you write down who owns the ship — otherwise the "owned by" line just points at nobody.

Backstage's own warning is specific: spec.owner is a reference, not free text, and Backstage never validates that the target exists at write time — only at read time, silently. Ingest the org first, as a static location, so kubestronaut-crew already exists by the time the Component below points at it:

# kubestronaut-portal/org.yaml — a static location, loaded before any Component
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
  name: kubestronaut-crew
spec:
  type: team
  children: []
---
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
  name: you   # swap in your own GitHub username
spec:
  memberOf: [kubestronaut-crew]
# kubestronaut-portal/app-config.local.yaml
catalog:
  rules:
    - allow: [Component, API, Resource, System, Group, User, Location]
  locations:
    - type: file
      target: ../org.yaml      # org data FIRST, so ownedBy resolves on the very next section

Restart yarn dev and confirm both entities show up under Catalog → Teams before moving on. Getting this order backwards doesn't error — it just produces the exact dangling-owner reference the tools page warns about: an ownership card that renders and points at nothing.

catalog-info.yaml: registering kubestronaut-tracker itself

☺ Like you're 10: One card, sitting right next to the code it describes, so it can never quietly go out of date the way a separate wiki page always does.

This file lives at the root of the kubestronaut-tracker repository — a new top-level file, alongside app/, deploy/, mesh/, policy/, and observability/ from the parts before this one:

# kubestronaut-tracker/catalog-info.yaml — repo root
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: kubestronaut-tracker
  title: Kubestronaut Tracker
  description: Tracks your own progress through the sixteen-exam ladder.
  tags: [node, postgres, gitops, mesh]
  annotations:
    backstage.io/techdocs-ref: dir:portal          # docs live under portal/, not repo root
    backstage.io/kubernetes-id: kubestronaut-tracker
    argocd/app-name: kubestronaut-tracker
    github.com/project-slug: you/kubestronaut-tracker
  links:
    - url: http://localhost:3000/d/kubestronaut-tracker
      title: Grafana dashboard
      icon: dashboard
spec:
  type: service
  lifecycle: experimental
  owner: group:default/kubestronaut-crew
  system: kubestronaut-tracker-system
  dependsOn:
    - resource:default/kubestronaut-tracker-db
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
  name: kubestronaut-tracker-db
  description: Single-replica Postgres backing kubestronaut-tracker, from Part 1.
spec:
  type: database
  owner: group:default/kubestronaut-crew
  system: kubestronaut-tracker-system

Register it the simple way for one service — paste the file's raw URL into Create → Register Existing Component in the portal UI, which creates exactly one Location. That's a real, named liability at scale (nothing removes the Location if the repo disappears), but it's the honest, correct choice for a capstone with exactly one service — reaching for an entity provider here would be solving a problem this repo doesn't have yet.

⚠ Grafana's port above is a placeholder for yours

The links URL assumes you've run kubectl port-forward svc/kube-prometheus-stack-grafana -n platform 3000:80 from Part 4's stack — adjust the port, and the dashboard slug, to whatever your own Helm release actually named its Service and dashboard. A link that 404s is worse than no link: it's the exact dangling-edge failure the portal model page warns looks fine right up until someone clicks it.

Wiring the Kubernetes tab to the kind cluster

☺ Like you're 10: One narrow key, cut to open exactly one door — enough for the board to read live pod status, not enough to touch anything.

The Kubernetes tab needs its own read-only credential, scoped down deliberately — the same least-privilege habit Part 3's Kyverno policies already insist on for the workload itself:

# kubestronaut-tracker/deploy/argocd/backstage-viewer-rbac.yaml — apply into platform
apiVersion: v1
kind: ServiceAccount
metadata: { name: backstage-viewer, namespace: platform }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata: { name: backstage-viewer }
rules:
  - apiGroups: ["", "apps", "argoproj.io"]
    resources: ["pods", "services", "deployments", "replicasets", "rollouts", "ingresses"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata: { name: backstage-viewer }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: backstage-viewer }
subjects:
  - kind: ServiceAccount
    name: backstage-viewer
    namespace: platform
kubectl apply -f deploy/argocd/backstage-viewer-rbac.yaml
export KIND_SA_TOKEN=$(kubectl create token backstage-viewer -n platform --duration=8760h)
kubectl cluster-info --context kind-kubestronaut-dev   # copy the "Kubernetes control plane" URL
# kubestronaut-portal/app-config.local.yaml — append
kubernetes:
  serviceLocatorMethod: { type: 'multiTenant' }
  clusterLocatorMethods:
    - type: 'config'
      clusters:
        - name: kubestronaut-dev
          url: ${KIND_API_SERVER}        # from kubectl cluster-info above
          authProvider: 'serviceAccount'
          serviceAccountToken: ${KIND_SA_TOKEN}
          skipTLSVerify: true            # kind's local, self-signed CA — throwaway cluster ONLY

One label has to exist on the live objects for any of this to resolve: add backstage.io/kubernetes-id: kubestronaut-tracker to the Deployment (or Rollout, from Part 2) and Service specs your Part 1 manifests already define under deploy/argocd, commit, and let Argo CD sync the change in on its own — the exact self-healing loop Part 1 proved, put to work on infrastructure this time instead of the app itself.

⚠ A blank Kubernetes tab is almost always this label, not a bug

Per Backstage's own gotchas, an empty tab on an otherwise-healthy entity is nine times out of ten a string mismatch: backstage.io/kubernetes-id on the entity doesn't match the actual label on the live Deployment. Check kubectl get deploy kubestronaut-tracker -n tracker -o jsonpath='{.metadata.labels}' before assuming anything about the plugin or the token is broken.

Wiring the Argo CD tab

☺ Like you're 10: Same board, a second live light — this one asking Recon's own reconciler directly, instead of asking Kubernetes.

Backstage ships no first-party Argo CD plugin; the RoadieHQ community plugin is the standard choice, and it talks to the exact argocd-server API that the Argo CD page already covers:

yarn --cwd packages/app add @roadiehq/backstage-plugin-argo-cd
yarn --cwd packages/backend add @roadiehq/backstage-plugin-argo-cd-backend
kubectl port-forward svc/argocd-server -n platform 8080:443 &
# kubestronaut-portal/app-config.local.yaml — append
argocd:
  username: ${ARGOCD_USERNAME}
  password: ${ARGOCD_PASSWORD}
  appLocatorMethods:
    - type: config
      instances:
        - name: kubestronaut-dev
          url: https://localhost:8080   # the port-forward above

proxy:
  '/argocd/api':
    target: https://localhost:8080/api/v1/
    changeOrigin: true
    secure: false

Both tabs render only once packages/app/src/components/catalog/EntityPage.tsx actually routes to them — this is CBA's largest domain, Customizing Backstage at 32%, in miniature: real edits to the generated React app, not configuration alone. Add both routes to the service entity's page:

// packages/app/src/components/catalog/EntityPage.tsx — inside the service entity's EntityLayout
import { EntityKubernetesContent, isKubernetesAvailable } from '@backstage/plugin-kubernetes';
import { EntityArgoCDOverviewCard, isArgocdAvailable } from '@roadiehq/backstage-plugin-argo-cd';

// ...inside <EntityLayout> for kind: Component, type: service
<EntityLayout.Route path="/kubernetes" title="Kubernetes" if={isKubernetesAvailable}>
  <EntityKubernetesContent refreshIntervalMs={10000} />
</EntityLayout.Route>
<EntityLayout.Route path="/argocd" title="Argo CD" if={isArgocdAvailable}>
  <EntityArgoCDOverviewCard />
</EntityLayout.Route>

The if={isKubernetesAvailable} / if={isArgocdAvailable} guards matter beyond tidiness — without them, every service in the catalog would show both tabs, even entities with no backstage.io/kubernetes-id or argocd/app-name annotation at all, rendering an empty, confusing shell instead of simply not showing the tab.

Git repo catalog-info.yaml + portal/ source of the entity itself Kubernetes API kind cluster via backstage-viewer token Argo CD server platform namespace via port-forward :8080 Backstage backend packages/backend — :7007 catalog + kubernetes-backend + argo-cd-backend + techdocs-backend SQLite (dev-only) underneath Backstage frontend packages/app — :3000 EntityPage.tsx routes Docs tab — TechDocs Kubernetes tab — live pods Argo CD tab — sync + health Three independent sources, one entity page, three tabs.

TechDocs: the manual, rendered where the service already lives

☺ Like you're 10: The instruction booklet lives in the same folder as the ship's own blueprints — never a separate binder someone forgets to update.

Add one small directory to kubestronaut-tracker's repo — portal/, the same top-level path the world-map at the top of the lab hub reserved for this part — and point the techdocs-ref annotation above at it, not at the repo root:

# kubestronaut-tracker/portal/mkdocs.yml
site_name: 'Kubestronaut Tracker'
nav:
  - Home: index.md
plugins: [techdocs-core]
# kubestronaut-tracker/portal/docs/index.md
# Kubestronaut Tracker

Tracks your own progress through the sixteen-exam ladder. `GET /health`,
`GET /certs`, `POST /certs/:id/complete`, `GET /progress`.

- GitOps: synced by Argo CD, Part 1
- Delivery: Argo Rollouts canary, Part 2
- Mesh & policy: Istio strict mTLS + Kyverno enforce, Part 3
- Observability: Grafana dashboard + 2 alerts, Part 4
# kubestronaut-portal/app-config.local.yaml — append
techdocs:
  builder: 'local'              # built on request, in-process — fine at this scale
  generator: { runIn: 'docker' } # uses the official techdocs-container image, no local mkdocs install
  publisher: { type: 'local' }

builder: 'local' is the right call for one service on one laptop; Backstage's own page uses builder: 'external' for its production example precisely because building docs on every request doesn't scale past a handful of services — the same tradeoff this page already made for SQLite over Postgres, made consistently rather than mixed. Restart yarn dev, open the entity's Docs tab, and confirm it builds and renders without a red error banner.

✓ Checkpoint — all four tabs, in order

Before moving to the milestones below, open kubestronaut-tracker's entity page once end to end: the Overview tab shows owner: kubestronaut-crew resolved to a real Group, not a broken link; Kubernetes shows live, running pods; Argo CD shows Synced/Healthy; Docs renders the page above. Four tabs, four different systems, one URL.

What "done" looks like for Part 5

☺ Like you're 10: A stranger to this whole mission can open one page and know everything — who owns it, whether it's healthy, and how to learn more — without ever finding you first.

At the end of this part, kubestronaut-tracker is a real catalog entity with a resolved owner, a Kubernetes tab reading live state from your kind cluster, an Argo CD tab reading sync and health from Part 1's Application, and a Docs tab rendering TechDocs built straight from the repo. This is also the end of the five-part capstone: the same catalog-info.yaml written above sits in the very repo every other part has been shaping since Part 1 — the loop the lab hub's own diagram draws as a dashed arrow curving back from Part 5 to the git repo at the top.

0 / 10 milestones complete
1Re-confirm Parts 1–4's own done-when lines still hold
Check the Argo CD Application is Synced/Healthy, the canary object exists, Istio + Kyverno are both live, and Part 4's dashboard shows real data.
Done when: you can point at each of the four without re-reading its own part.
2Scaffold kubestronaut-portal and get yarn dev running
npx @backstage/create-app@latest --path kubestronaut-portal, then yarn install && yarn dev.
Done when: the empty catalog loads at localhost:3000 with no build error.
3Ingest org.yaml — Group and User — before anything else
Write org.yaml, add it under catalog.locations as a static file location, restart.
Done when: kubestronaut-crew and your own User both show under Catalog → Teams.
4Write catalog-info.yaml at the repo root and register it
Commit the Component + Resource YAML above, register the raw file URL via Create → Register Existing Component.
Done when: the entity page loads with owner: kubestronaut-crew resolved, not a dangling reference.
5Add the backstage.io/kubernetes-id label and the viewer ServiceAccount
Apply backstage-viewer-rbac.yaml; add the label to the Part 1 Deployment/Rollout and Service; let Argo CD sync it in.
Done when: kubectl get deploy kubestronaut-tracker -n tracker -o jsonpath='{.metadata.labels}' shows the label.
6Configure the kubernetes: block and confirm the tab
Get the token and API server URL, add the config block above, restart.
Done when: the Kubernetes tab on the entity page shows live, running pods from tracker.
7Install the RoadieHQ Argo CD plugin and configure it
Add both packages, port-forward argocd-server, add the argocd: and proxy: blocks above.
Done when: the Argo CD tab shows Synced / Healthy matching what argocd app get kubestronaut-tracker reports on the CLI.
8Route both tabs in EntityPage.tsx
Add the EntityLayout.Route blocks above, guarded by isKubernetesAvailable / isArgocdAvailable.
Done when: both tabs appear on kubestronaut-tracker's page and neither appears on an entity with no matching annotation.
9Write portal/mkdocs.yml + portal/docs/index.md and render TechDocs
Add both files, set techdocs-ref: dir:portal, configure the techdocs: block, restart.
Done when: the Docs tab builds and renders with no red error banner.
10Say out loud that the whole five-part loop is closed
Open the entity page once, cold, and walk all four tabs without looking anything up first.
Done when: you can describe, from the running portal and not from memory, what each of the five parts left behind and where this page shows it.
🦆 Dot's-eye view

"I once inherited a service with a beautiful catalog entry — dashboard link, Kubernetes tab, the works — and every single one of them was quietly stale, because whoever wired it up left the team eight months earlier and nobody re-pointed anything. The tabs didn't error. They just kept confidently showing last year's truth. What actually saved me wasn't the portal — it was that argocd app get and kubectl get pods still told the truth directly, and I could catch the drift by comparing the two. Wire these tabs, trust them day to day, but keep the muscle memory for asking the running system yourself. That's the whole habit this capstone has been building, one part at a time."

🎬 At Mission Control
🦋

Mira: Four tabs, one page. Kubernetes, Argo CD, docs, and an owner that actually resolves. That's the whole part.

🦊

Foxy: Why not just skip the org.yaml step and hardcode an owner string? Feels like ceremony for one service.

🐢

Timmy the Turtle: Because "feels fine for one service" is exactly how a dangling owner reference gets born, Foxy — it looks identical to a real one right up until someone clicks it during an actual incident.

🤖

Recon: And the Argo CD tab is only ever as honest as the Application it's reading — same loop I've been running since Part 1, just with a second window open on it now.

👺

Gizmo: Or — skip TechDocs entirely, nobody reads docs anyway. Ship the two live tabs and call it done! 🤑

🦋

Mira: A live tab tells you the state right now. Docs tell the next person why — what the four endpoints do, which part built which piece. Both, Gizmo. That's the difference between a status page and a portal.

🦫

Benny the Beaver: Five parts, one loop, closed. I've watched every bay of this ship built by hand — this is the first time it's all visible from one seat.

🐢 Timmy's checkpoint

1. Why must org.yaml be ingested before catalog-info.yaml, and what does getting the order backwards actually produce — an error, or something quieter? 2. What single string has to match exactly for the Kubernetes tab to show real pods, and where does it need to be written twice? 3. What does backstage.io/techdocs-ref: dir:portal tell Backstage that dir:. would have told it instead, and why is dir:. wrong here? 4. Why does EntityLayout.Route get wrapped in if={isKubernetesAvailable} rather than always rendering the tab? 5. Of the nine project certifications this course covers, which two never got touched by any of the five capstone parts, and why not?

Check your answers
  1. Because spec.owner is a reference the catalog only resolves at read time, not write time. Getting the order backwards produces no error at all — the Component still ingests fine, but its ownership card silently points at a Group that doesn't exist yet, exactly the dangling-reference failure mode the Backstage page and the portal model page both warn looks fine until someone actually needs it.
  2. backstage.io/kubernetes-id. It has to appear once as an annotation on the Backstage entity (catalog-info.yaml) and once as an actual Kubernetes label on the live Deployment and Service objects — the plugin matches the two strings, and a mismatch between them is the single most common cause of a blank tab.
  3. dir:portal tells Backstage the TechDocs source — mkdocs.yml and docs/ — lives in a portal/ subdirectory of the repo. dir:. would point at the repo root instead, where catalog-info.yaml itself lives, not where this page's mkdocs.yml actually sits — the Docs tab would fail to find a config file at all.
  4. Without the guard, every entity in the catalog — including ones with no backstage.io/kubernetes-id or argocd/app-name annotation at all — would render an empty, broken-looking tab instead of simply not showing it. The guard makes the tab's presence itself meaningful: if you see it, the entity is actually wired up.
  5. CCA (Cilium's eBPF data plane — a different networking layer from the Istio mesh this build uses) and LFCS (the Linux Foundation's performance-based exam on the operating system underneath the cluster, not the application-level workloads this capstone builds). Both are honestly out of scope for an app-level capstone like this one — see the capstone hub's own answer key for the fuller version of this same question.

Part 5 closes the loop: kubestronaut-tracker is now a real catalog entity with a resolved owner, a live Kubernetes tab, a live Argo CD tab, and rendered TechDocs, all reachable from one page. That's the whole five-part capstone, seven of the nine project certifications this course covers, and one running system that can answer for itself. Step back to the full lab track to see how this part fits the other four, revisit Backstage and The Backstage Portal Model for the concepts behind what you just built, and go verify current CBA weights, format, and duration on CBA — the exam before you register for anything.