Capstone · Part 4 of 6 · Backstage Self-Service Portal

Capstone Part 4: Backstage Self-Service Portal

Parts 1 through 3 built a platform nobody outside your terminal can see: a self-reconciling cluster, a real pipeline shipping ledger through a canary, and a new Kubernetes-native API for it. Part 4 builds the front door. You stand up Backstage, register ledger in its Software Catalog with a catalog-info.yaml, and author a Software Template so thin that "start a new service the right way" collapses from copy-pasting ledger's five folders by hand into a thirty-second form. This is the exact pairing the exam calls out: Self-Service & Developer Portals, CNPE Domain 3, at 25% of the exam.

⚖ CNPA vs CNPE — That 25% weighting is CNPE-specific — CNPA has no equivalent hands-on lab and no per-domain percentage like it. But the underlying ideas here — a Software Catalog, golden-path templates, self-service without hand-written YAML — are still fair game for CNPA's closed-book recall, just tested as concepts rather than something you build.

⚠ Where you are arriving from, and where you're headed

Arriving: the platform-dev cluster from Part 1, with Argo CD's root App-of-Apps reconciling from your platform-capstone repo; the pipeline-built ledger:TAG image shipping through an Argo Rollouts canary (10% → 50% → 100%) in the ledger namespace from Part 2; and, from Part 3, a brand-new Kubernetes noun the platform now understands — kind: LedgerDatabase — with a ledger-db-operator Deployment running in platform that reconciles a LedgerDatabase named ledger-db in the ledger namespace into a real Postgres StatefulSet, headless Service, and credentials Secret, all self-healing. Leaving this page: Backstage running in the platform namespace, reconciled by the same Argo CD like everything else in this capstone; ledger fully described in the Software Catalog with working TechDocs; and a golden-path-service Software Template that puts a form in front of that exact LedgerDatabase CRD — so asking for a new service and its database is one click, not eight lines of hand-written YAML. Running it once produced a second real service — invoices, with its own LedgerDatabase — as a new Git repo plus a merged pull request into platform-capstone, with zero hand-written Kubernetes manifests and zero kubectl apply. Part 5 picks up here and wires golden-signals dashboards and alerts for both ledger and invoices — plural, because by the end of this page you have two services doing the same GitOps dance, and one of them you never touched by hand.

☺ Explain it like I'm 10

You've spent three afternoons building an amazing vending machine behind a curtain: it makes a payments robot (ledger), it fixes itself when broken, it even builds its own spare parts. Today you pull back the curtain and put a glass front on it with a big friendly button. Press "small database" — no wait, wrong course — press "New Service," fill in three boxes, and thirty seconds later a whole new robot exists: its own little house (a Git repo), its own conveyor belt (a pipeline), its own room in the cluster — all built the exact same safe way ledger was built, because the button is the recipe, followed automatically, every time.

🦋🐿️🦆Your hosts for this part: Mira the Butterfly & Nutty the Squirrel — Mira hides the scary YAML behind one beautiful button and wires the storefront, Nutty makes sure it's a button developers actually asked for — with 🦆 Dot the Duck front and centre as the developer who presses it.

What this part assumes and what it produces

☺ Like you're 10: Everything from before still works exactly the same — you're just adding a glass front to it.

You need a working Node.js toolchain (Node 20 LTS is the safe choice — check with node --version), Yarn (Backstage's own tooling), Docker, and the platform-dev cluster and platform-capstone repo from Part 1, still reconciling. You also need a GitHub personal access token with repo scope exported as GITHUB_TOKEN — Backstage's scaffolder needs real write access to create the invoices repo and open a pull request against platform-capstone, exactly the way Lab 11's image automation needed write access to commit a tag bump. Nothing from Parts 1–3 gets rebuilt or restructured; this part only adds.

This page assumes you've read Self-Service & Developer Portals and, ideally, the deeper Backstage tool reference — both explain why a Software Catalog and a scaffolder work the way they do. This page is the hands, not the theory.

Extending the shared world model

Three new names join the table every earlier part has been keeping current:

ThingNameIntroduced
The portalbackstage Deployment, in the platform namespacePart 4 — this page
The Software Catalog entryledger Component (plus a Resource for Part 3's LedgerDatabase)Part 4
The golden pathgolden-path-service Software TemplatePart 4
The second service, born from the templateinvoices — a new repo, plus apps/invoices.yaml and invoices/ in platform-capstonePart 4
◆ Key idea

Notice the repo shape this capstone has kept since Part 1: apps/ holds Argo CD Application pointers, and each service gets its own sibling folder of raw manifests — ledger/, and now invoices/. The Software Template's whole job is to produce exactly that shape automatically, so onboarding a new service never means inventing a new repo layout by hand.

Standing up Backstage — locally, then on platform-dev

☺ Like you're 10: First you build the shop window on your own desk and check it works, then you move it into the cluster so it lives alongside everything else it's the front door for.

Scaffold a brand-new Backstage app. This is a real TypeScript monorepo you now own, exactly as the Backstage reference warns — not a product you configure once and forget.

npx @backstage/create-app@latest --path platform-portal
cd platform-portal
yarn install

# run it locally first — frontend on :3000, backend on :7007
yarn dev

Open localhost:3000. You'll see an empty catalog — that's expected, you haven't pointed it at anything yet. Before wiring real data in, configure app-config.yaml at the repo root:

# platform-portal/app-config.yaml
app:
  baseUrl: http://backstage.platform.svc.cluster.local:7007
backend:
  baseUrl: http://backstage.platform.svc.cluster.local:7007
  listen: { port: 7007 }
  database:
    client: better-sqlite3            # fine for this throwaway lab cluster;
    connection: ':memory:'            # a real deployment needs Postgres — see backstage.html

integrations:
  github:
    - host: github.com
      token: ${GITHUB_TOKEN}          # a scoped PAT is fine for a lab; a GitHub App for anything real

techdocs:
  builder: 'local'                    # fine for this lab; use 'external' + object storage in production
  generator: { runIn: 'docker' }
  publisher: { type: 'local' }

catalog:
  rules:
    - allow: [Component, API, Resource, System, Domain, Group, User, Location, Template]
  locations:
    - type: url
      target: https://github.com/YOU/platform-capstone/blob/main/catalog/ledger/catalog-info.yaml
    - type: url
      target: https://github.com/YOU/platform-capstone/blob/main/catalog/templates/golden-path-service/template.yaml
      rules: [ { allow: [Template] } ]

kubernetes:
  serviceLocatorMethod: { type: 'multiTenant' }
  clusterLocatorMethods:
    - type: 'config'
      clusters:
        - name: platform-dev
          url: https://kubernetes.default.svc
          authProvider: 'serviceAccount'
⚠ SQLite and a raw PAT are lab shortcuts, not the real answer

better-sqlite3 loses the whole catalog on every pod restart, and a token pasted straight into config is a plaintext secret in Git the moment you commit this file. Both are acceptable only because this is a throwaway kind cluster with nothing real behind it. For anything beyond this lab: Postgres for the database, and the token delivered via a Kubernetes Secret (ideally through External Secrets) rather than baked into app-config.yaml at all — see the Backstage reference's gotchas section for exactly why.

Packaging Backstage as a container and getting it into platform-dev

Backstage ships with a backend Dockerfile out of the box. Build it, then load it straight into your kind cluster's node — no registry push needed for a local lab:

yarn build:all
yarn build:backend --config app-config.yaml
docker image build . -f packages/backend/Dockerfile --tag platform-portal:1

kind load docker-image platform-portal:1 --name platform-dev

Commit the Kubernetes manifests for Backstage into platform-addons/backstage/ — the same top-level folder every remaining part of this capstone will add platform add-ons under, first named back in Part 1:

# platform-addons/backstage/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backstage
  namespace: platform
  labels: { app: backstage }
spec:
  replicas: 1
  selector: { matchLabels: { app: backstage } }
  template:
    metadata: { labels: { app: backstage } }
    spec:
      containers:
        - name: backstage
          image: platform-portal:1
          imagePullPolicy: IfNotPresent   # image was kind-loaded, not pulled from a registry
          ports:
            - containerPort: 7007
          env:
            - name: GITHUB_TOKEN
              valueFrom:
                secretKeyRef: { name: backstage-github-token, key: token }
          resources:
            requests: { cpu: 100m, memory: 256Mi }
            limits: { cpu: 500m, memory: 512Mi }
---
# platform-addons/backstage/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: backstage
  namespace: platform
spec:
  selector: { app: backstage }
  ports:
    - port: 7007
      targetPort: 7007
# apps/backstage.yaml — one more child of the Part 1 App-of-Apps root
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: backstage
  namespace: platform
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/YOU/platform-capstone.git
    targetRevision: main
    path: platform-addons/backstage
  destination:
    server: https://kubernetes.default.svc
    namespace: platform
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [ CreateNamespace=true ]

Create the token Secret by hand once (it is not committed to Git), then commit and push the three files above:

kubectl -n platform create secret generic backstage-github-token \
  --from-literal=token=$GITHUB_TOKEN

git add platform-addons/backstage apps/backstage.yaml
git commit -m "add Backstage to the platform"
git push

kubectl -n platform get applications backstage -w
# NAME        SYNC STATUS   HEALTH STATUS
# backstage   Synced        Healthy

kubectl -n platform port-forward svc/backstage 7007:7007
# open http://localhost:7007

☺ Like you're 10: Notice what just happened — you didn't kubectl apply the portal into existence. You added a file to Git and the same robot from Part 1 built it for you. The portal that will teach Dot self-service arrived through self-service.

Registering ledger in the Software Catalog

☺ Like you're 10: A card in the catalogue that says who ledger is, who owns it, and where its instruction booklet lives.

Add a catalog-info.yaml describing ledger. Keep it inside platform-capstone under a new catalog/ folder — since this capstone keeps every service's manifests inside one repo rather than one repo per service, its catalog description lives there too, right beside the manifests it describes:

# platform-capstone/catalog/ledger/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: ledger
  title: Ledger Service
  description: Small payments/ledger-style service — the capstone's one running workload.
  tags: [payments, capstone]
  annotations:
    backstage.io/techdocs-ref: dir:.
    backstage.io/kubernetes-id: ledger              # matches the app=ledger label on its pods
    argocd/app-name: ledger                         # lights up the Argo CD tab — Part 1's Application
    github.com/project-slug: YOU/platform-capstone
  links:
    - url: https://github.com/YOU/platform-capstone/tree/main/ledger
      title: Manifests
      icon: github
spec:
  type: service
  lifecycle: production
  owner: group:default/platform-team
  system: capstone
  dependsOn:
    - resource:default/ledger-db
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
  name: ledger-db
  description: >-
    A LedgerDatabase custom resource (Part 3) — reconciled by the ledger-db-operator
    into a Postgres StatefulSet, headless Service and credentials Secret in the ledger namespace.
spec:
  type: database
  owner: group:default/platform-team
  system: capstone
---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
  name: platform-team
spec:
  type: team
  children: []
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
  name: capstone
  description: Everything built across the six capstone parts.
spec:
  owner: group:default/platform-team

Commit and push it, then confirm the catalog picked it up — either wait for the url location's refresh interval or hit the catalog API directly to force one:

git add catalog/ledger/catalog-info.yaml
git commit -m "register ledger in the Software Catalog"
git push

curl -s -X POST http://localhost:7007/api/catalog/locations \
  -H 'Content-Type: application/json' \
  -d '{"type":"url","target":"https://github.com/YOU/platform-capstone/blob/main/catalog/ledger/catalog-info.yaml"}'

curl -s "http://localhost:7007/api/catalog/entities/by-name/component/default/ledger" | jq '.metadata.name, .spec.owner'

Open localhost:7007/catalog in a browser: ledger now has its own page with an owner, a system, a dependency arrow to the Part 3 API, and — because of the argocd/app-name annotation — an Argo CD tab showing the exact same Synced/Healthy status argocd app get ledger reports on the command line. One page now answers "what is this, who owns it, is it deployed correctly" — the question Dot used to ask in Slack.

TechDocs — the ledger runbook lives next to the code

☺ Like you're 10: The instruction booklet sits in the same box as the toy, so it never goes missing or gets out of date.

Add a docs folder to platform-capstone, beside ledger/'s manifests and catalog/ledger/catalog-info.yaml:

platform-capstone/
├── apps/
├── ledger/
├── catalog/
│   └── ledger/
│       ├── catalog-info.yaml
│       ├── mkdocs.yml
│       └── docs/
│           ├── index.md
│           └── runbook.md
# catalog/ledger/mkdocs.yml
site_name: 'Ledger Service'
nav:
  - Home: index.md
  - Runbook: runbook.md
plugins:
  - techdocs-core
# catalog/ledger/docs/runbook.md — the first real page a future on-call reads
# Ledger runbook

Ledger is a small payments/ledger-style service. It ships through Argo CD from
`ledger/` in this repo, via an Argo Rollouts canary (10% -> 50% -> 100%, see Part 2).

## If it's Degraded
1. `kubectl -n ledger get pods` — look for CrashLoopBackOff or ImagePullBackOff.
2. `kubectl -n ledger describe pod <pod>` for the real event.
3. `argocd app get ledger` — confirm Synced vs Healthy are not the same thing.

## Rollback
`argocd app rollback ledger <revision>` — then fix Git, or selfHeal reverts you again.

Update catalog-info.yaml's backstage.io/techdocs-ref annotation to point at this subfolder if you keep docs alongside the catalog file rather than at the repo root (dir:. for the same folder, or a relative dir:../../ledger if you keep docs beside the manifests instead — either is fine, just be consistent). Rebuild and confirm the Docs tab renders on ledger's catalog page. This is a small thing to skip and a large thing to have: the next person paged for ledger reads this runbook from the same page that shows them the failing pod, instead of searching a wiki that hasn't been touched since Part 1.

Authoring the golden-path Software Template

☺ Like you're 10: A fill-in-the-blanks form that, on submit, builds a whole new toy exactly the way ledger was built — including its own tiny database — and mails the box straight to the shelf.

This is the button, and it fulfils the promise Part 3 made when it built the LedgerDatabase CRD: "Mira and Nutty put a Backstage form in front of this exact CRD, so scaffolding a brand-new service's database becomes one click instead of eight lines of YAML." One template.yaml plus a skeleton/ directory turn "start a new service correctly, with its own database" from copying ledger/'s files and hand-writing a LedgerDatabase into a form and a click. Its steps map exactly onto the shape you built by hand in Parts 1 through 3: render a repo, publish it, wire it into platform-capstone's apps/ the way ledger.yaml already is — complete with its own LedgerDatabase claim — and register it in the catalog.

platform-capstone/catalog/templates/golden-path-service/
├── template.yaml
└── skeleton/
    ├── Dockerfile
    ├── ci-pipeline.yaml            # Tekton Pipeline + PipelineRun, Kaniko build
    ├── catalog-info.yaml           # ${{ values.* }} placeholders
    └── gitops/
        ├── application.yaml        # -> becomes apps/<name>.yaml in platform-capstone
        ├── namespace.yaml
        ├── rollout.yaml             # Argo Rollouts canary, same shape as ledger's
        ├── ledger-database.yaml     # a LedgerDatabase claim — Part 3's CRD, one click
        ├── service.yaml
        └── kustomization.yaml
# catalog/templates/golden-path-service/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: golden-path-service
  title: New Service (Golden Path)
  description: A repo, a Tekton+Kaniko pipeline, an Argo Rollouts canary, and a LedgerDatabase — wired into platform-capstone automatically.
  tags: [recommended, capstone]
spec:
  owner: group:default/platform-team
  type: service

  parameters:                                   # ── the form Dot 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
          ui:options: { catalogFilter: { kind: Group } }
        replicas:
          title: Starting replica count
          type: integer
          default: 2
          enum: [1, 2, 3]
        storageGB:
          title: Database size (GB)
          type: integer
          default: 5
          minimum: 1                    # matches LedgerDatabaseSpec's own +kubebuilder:validation range
          maximum: 100                   # the CRD's admission validation would reject anything outside this anyway

  steps:                                        # ── what runs on submit ──
    - id: fetch-service
      name: Render the service skeleton
      action: fetch:template
      input:
        url: ./skeleton
        targetPath: ./service
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}
          replicas: ${{ parameters.replicas }}

    - id: publish-service
      name: Create the service's own repo
      action: publish:github
      input:
        repoUrl: github.com?owner=YOU&repo=${{ parameters.name }}
        sourcePath: ./service
        defaultBranch: main
        repoVisibility: internal

    - id: fetch-gitops-pr
      name: Render the platform-capstone additions
      action: fetch:template
      input:
        url: ./skeleton/gitops
        targetPath: ./gitops-pr
        values:
          name: ${{ parameters.name }}
          replicas: ${{ parameters.replicas }}
          storageGB: ${{ parameters.storageGB }}
          serviceRepoUrl: ${{ steps.publish-service.output.remoteUrl }}

    - id: pr-gitops
      name: Open a PR against platform-capstone
      action: publish:github:pull-request
      input:
        repoUrl: github.com?owner=YOU&repo=platform-capstone
        branchName: add-${{ parameters.name }}
        title: "Add ${{ parameters.name }} to the golden path"
        description: Scaffolded by Backstage — adds apps/${{ parameters.name }}.yaml and ${{ parameters.name }}/
        sourcePath: ./gitops-pr

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

  output:
    links:
      - title: Service repository
        url: ${{ steps.publish-service.output.remoteUrl }}
      - title: platform-capstone pull request
        url: ${{ steps.pr-gitops.output.remoteUrl }}
      - title: Open in catalog
        icon: catalog
        entityRef: ${{ steps.register.output.entityRef }}

The skeleton files behind that template are ordinary manifests with ${{ values.* }} placeholders — the same shape Part 2's pipeline and canary already proved for ledger, just parameterised:

# skeleton/ci-pipeline.yaml — Tekton, mirroring Part 2's build for ledger
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: ${{ values.name }}-build
spec:
  params:
    - name: image-tag
  workspaces:
    - name: source
  tasks:
    - name: build-and-push
      taskRef: { name: kaniko }
      workspaces: [ { name: source, workspace: source } ]
      params:
        - name: IMAGE
          value: registry.local/${{ values.name }}:$(params.image-tag)
---
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
  name: ${{ values.name }}-build-run
spec:
  pipelineRef: { name: "${{ values.name }}-build" }
  params: [ { name: image-tag, value: "0.1.0" } ]
  workspaces:
    - name: source
      volumeClaimTemplate:
        spec:
          accessModes: [ReadWriteOnce]
          resources: { requests: { storage: 1Gi } }
# skeleton/gitops/rollout.yaml — same 10% -> 50% -> 100% canary shape as ledger's, Part 2
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: ${{ values.name }}
  namespace: ${{ values.name }}
spec:
  replicas: ${{ values.replicas }}
  selector: { matchLabels: { app: "${{ values.name }}" } }
  template:
    metadata: { labels: { app: "${{ values.name }}" } }
    spec:
      containers:
        - name: ${{ values.name }}
          image: registry.local/${{ values.name }}:0.1.0
          ports: [ { containerPort: 8080 } ]
  strategy:
    canary:
      steps:
        - setWeight: 10
        - pause: { duration: 60 }
        - setWeight: 50
        - pause: { duration: 60 }
        - setWeight: 100
---
# skeleton/gitops/ledger-database.yaml — Part 3's CRD, requested with a form instead of eight hand-written lines
apiVersion: platform.acme.io/v1alpha1
kind: LedgerDatabase
metadata:
  name: ${{ values.name }}-db
  namespace: ${{ values.name }}
spec:
  engine: postgres
  storageGB: ${{ values.storageGB }}
  highAvailability: false
---
# skeleton/gitops/application.yaml -> committed as apps/${{ values.name }}.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: ${{ values.name }}
  namespace: platform
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/YOU/platform-capstone.git
    targetRevision: main
    path: ${{ values.name }}
  destination:
    server: https://kubernetes.default.svc
    namespace: ${{ values.name }}
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [ CreateNamespace=true ]

Register the template's own location in app-config.yaml (already shown above, under catalog.locations with rules: [ { allow: [Template] } ]), commit and push everything under catalog/templates/golden-path-service/, then confirm it appears under Create in the portal UI, or via the API:

curl -s "http://localhost:7007/api/catalog/entities/by-name/template/default/golden-path-service" \
  | jq '.metadata.name'

One click, end to end — scaffolding invoices

☺ Like you're 10: Now you actually press the button, once, for a brand-new toy called invoices — and watch every earlier part of the capstone show up to do its one job.

In the portal UI, open Create, pick New Service (Golden Path), and fill in name: invoices, pick an owner, leave replicas: 2 and storageGB: 5. Hit create. If you'd rather drive it from a terminal — useful for proving the whole path is scriptable, not just clickable — the scaffolder is a plain REST API underneath the form:

curl -s -X POST http://localhost:7007/api/scaffolder/v2/tasks \
  -H 'Content-Type: application/json' \
  -d '{
    "templateRef": "template:default/golden-path-service",
    "values": { "name": "invoices", "owner": "group:default/platform-team", "replicas": 2, "storageGB": 5 }
  }' | jq '.id'

# watch it run
curl -s "http://localhost:7007/api/scaffolder/v2/tasks/<task-id>/eventstream"

Trace what happens, matching it against the template's steps:

  1. The scaffolder renders skeleton/ with name: invoices substituted everywhere — a Dockerfile, the Tekton pipeline, a catalog-info.yaml.
  2. A brand-new repo, github.com/YOU/invoices, appears with that content — check with gh repo view YOU/invoices or open it in a browser.
  3. A second render produces apps/invoices.yaml and an invoices/ folder (namespace, Rollout, Service, a LedgerDatabase claim, kustomization) — the exact shape Part 1 hand-wrote for ledger, plus the database claim Part 3 taught the platform to understand.
  4. A pull request opens against platform-capstone on a branch named add-invoices, carrying those files. Confirm it: gh pr list --repo YOU/platform-capstone.
  5. invoices is registered in the Software Catalog, owned by the team you picked.

Review the PR like any other change to the platform — this is the moment a teammate would approve it in a real team — then merge it:

gh pr merge add-invoices --repo YOU/platform-capstone --merge

kubectl -n platform get applications invoices -w
# NAME       SYNC STATUS   HEALTH STATUS
# invoices   Synced        Healthy

kubectl -n invoices get rollout,svc,ldb
# rollout.argoproj.io/invoices        healthy
# service/invoices                    ClusterIP
# ledgerdatabase.platform.acme.io/invoices-db   postgres   5   Running   True

That last line is the one worth pausing on: the ledger-db-operator Deployment from Part 3 — unmodified, still living in platform, still watching every namespace via its ClusterRole — picked up a LedgerDatabase it had never seen before and provisioned a real Postgres StatefulSet for invoices, the moment the PR merged. Nobody taught the operator about invoices; it only ever knew about the kind, not the instance.

Sit with what just happened: you never wrote a Kubernetes manifest for invoices, never ran kubectl apply against it, never opened a cloud console. You filled in three form fields. Argo CD's root App-of-Apps — the same one from Part 1, completely unmodified — picked up apps/invoices.yaml the instant it merged and reconciled a brand-new namespace, canary Rollout and Service into existence. That is the self-service promise the paired lesson describes, made concrete on your own laptop with your own hands.

🦆 Dot fills in the form 🦋 Backstage scaffolder runs New repo invoices — code + CI PR to platform-capstone apps/invoices.yaml + invoices/ human reviews + merges 🤖 root Application unchanged since Part 1 invoices namespace Rollout + Service, running The only human action anywhere in this diagram is the merge — everything else reconciles itself.

Guardrails on the self-service path

☺ Like you're 10: The button only stays safe because the dangerous levers were never wired to it in the first place.

A scaffolder that can create repositories and open pull requests is a credentialed robot, exactly as the Backstage reference warns — and this capstone's GITHUB_TOKEN is a lab shortcut, not the production answer. Three guardrails matter here specifically, and two of them foreshadow the full security build in Part 6:

GuardrailWhat it enforces on this path
The template's own parametersThe name field's pattern rejects anything that isn't a safe DNS-label-shaped string — Dot cannot scaffold a service named ../../etc or with capital letters that break a namespace name.
catalog.rules in app-config.yamlLimits which entity kinds each location may register — the template location is restricted to allow: [Template] so a compromised template repo can't smuggle in an arbitrary Component pretending to own something it doesn't.
The permission framework (opt-in — turn it on)Without it, any authenticated user who reaches the portal can run any template — which, given the scaffolder's credentials, means anyone can create repositories in your org. Gate the golden-path template to specific groups before this ever leaves your laptop.
PR review, not auto-mergeThe template opens a pull request against platform-capstone; it never merges it. A human — or, later, a required status check — is still the last gate before anything reaches Argo CD.
⚠ Watch out

It's tempting to skip the review step because "it's just a lab" — resist that in your own head even here, because the habit is the point. The whole promise of self-service is that developers move fast because the path is safe by construction, not despite skipping the checks. A scaffolder with unscoped credentials and no permission policy is Gizmo's dream and a 2am incident's origin story.

What "done" looks like for Part 4

☺ Like you're 10: A shop window, a catalogue card for ledger, and proof that pressing the "new service" button really does build a whole new toy by itself.

At the end of this part your platform-dev cluster runs Backstage in the platform namespace, reconciled by Argo CD exactly like everything else in this capstone; ledger is fully described in the Software Catalog with a working TechDocs tab and a visible Argo CD sync status; a golden-path-service Software Template exists and has been proven, not just written — it produced a second real, running service, invoices, via a new repo and a merged pull request, with zero hand-written manifests. Nothing from Parts 1–3 changed shape; you only added a front door onto it. Part 5 starts from exactly this state and builds the full observability recipe — a ServiceMonitor, a golden-signals dashboard, an alert — against ledger in depth, milestone by milestone; then shows you the identical pattern extended to invoices as a proof that it's genuinely reusable, before deliberately breaking one of them so you can triage it live.

🎬 At the Platform Guild
🦊

Foxy: So all this Backstage work, and the button just... writes a pull request? That's it?

🦋

Mira: That's the whole trick, Foxy. I don't touch the cluster. I write to Git — the same Git Benny's robot has been watching since Part 1. The button is a very polite way of committing code.

🐿️

Nutty: And I made sure the form only asks three questions, because that's what developers actually told me they want. Nobody wants to fill in forty fields to get a Deployment.

👺

Gizmo: Boring! Just give the scaffolder's token full admin on the whole GitHub org and let it auto-merge its own PRs. Ship faster! 🤑

🐢

Timmy: That's how a bad template creates a hundred unreviewed services overnight, Gizmo. The PR stays a PR. A human — or a policy — reviews it, every time.

🦆

Dot: Honestly, I filled in a name, an owner, and a replica count. Ninety seconds later I had a repo, a pipeline and a running canary rollout. I didn't talk to anyone. That's the whole pitch, right there.

Milestones

☺ Like you're 10: Tick each box only once you've watched it happen on your own screen — especially milestone 11, where you actually press the button.

Work these in order — each depends on the cluster, repo, and portal state from the one before. Progress saves in this browser.

0 / 14 milestones complete
1Scaffold a Backstage app locally
npx @backstage/create-app@latest --path platform-portal, then yarn install && yarn dev.
Done when: localhost:3000 loads an empty portal with no errors in the terminal.
Concept: Backstage
2Configure app-config.yaml
Set the GitHub integration token, the database, and the two catalog.locations entries shown above.
Done when: yarn dev restarts with no config-validation errors.
3Build and containerize Backstage
yarn build:all, yarn build:backend --config app-config.yaml, then docker image build using packages/backend/Dockerfile.
Done when: docker image ls shows platform-portal:1.
4Load the image into platform-dev
kind load docker-image platform-portal:1 --name platform-dev.
Done when: the command exits 0 with no "image not present" warning.
5Commit Backstage's manifests and let Argo CD deploy it
Push platform-addons/backstage/deployment.yaml, service.yaml, and apps/backstage.yaml — never kubectl apply them yourself.
Done when: kubectl -n platform get applications backstage reports Synced/Healthy.
6Reach Backstage running in the cluster
kubectl -n platform port-forward svc/backstage 7007:7007, open localhost:7007.
Done when: the in-cluster portal loads, not just the local yarn dev copy.
7Write and push ledger's catalog-info.yaml
Commit the Component, Resource, Group, and System entities shown above into catalog/ledger/.
Done when: the file exists on main in platform-capstone.
8Confirm ledger appears in the Software Catalog
Force a location refresh via the catalog API, or wait for the poll, then open ledger's page in the portal.
Done when: curl .../api/catalog/entities/by-name/component/default/ledger returns its metadata, and the Argo CD tab shows real sync status.
9Wire up TechDocs for ledger
Add mkdocs.yml, docs/index.md, docs/runbook.md, and confirm the techdocs-ref annotation points at them.
Done when: the Docs tab on ledger's catalog page renders the runbook.
Concept: TechDocs
10Author and register the golden-path-service Template
Commit template.yaml and skeleton/ under catalog/templates/golden-path-service/, and add its location to app-config.yaml.
Done when: curl .../api/catalog/entities/by-name/template/default/golden-path-service returns the template, and it appears under Create in the UI.
11Run the template for a new service, invoices
Submit the form (or POST to /api/scaffolder/v2/tasks) with name: invoices.
Done when: a new repo github.com/YOU/invoices exists with the rendered skeleton.
12Confirm the pull request against platform-capstone
gh pr list --repo YOU/platform-capstone.
Done when: a PR titled "Add invoices to the golden path" is open, carrying apps/invoices.yaml and the invoices/ folder.
13Merge it and watch Argo CD — and Part 3's operator — do the rest
Merge the PR, then kubectl -n platform get applications invoices -w and kubectl -n invoices get rollout,svc,ldb.
Done when: invoices is Synced/Healthy, its Rollout is running, and its LedgerDatabase reaches Ready — with zero kubectl apply commands from you at any point.
14Say out loud what state you're leaving for Part 5
Confirm: Backstage running and reconciled; ledger and invoices both in the catalog with TechDocs; both Applications Synced/Healthy in kubectl -n platform get applications.
Done when: you can describe this state without looking anything up — it's the exact starting point Part 5 assumes.
🐢 Timmy's checkpoint

1. When the scaffolder finishes, what has it actually changed — the cluster, or Git? 2. Name the four steps the golden-path-service template runs, in order, and what each one produces. 3. Why does the template open a pull request against platform-capstone instead of committing straight to main? 4. What's the one thing that made Backstage itself deployable the exact same way as ledger? 5. Name two guardrails that stop the scaffolder's GitHub credentials from being a standing risk.

Check your answers
  1. Git. The scaffolder creates a new repo and opens a pull request — it never touches the cluster. Argo CD, watching Git exactly as it has since Part 1, is what actually creates anything in Kubernetes once the PR merges.
  2. 1) fetch:template on ./skeleton — renders the new service's own repo content (Dockerfile, CI pipeline, catalog-info.yaml). 2) publish:github — creates that repo. 3) a second fetch:template plus publish:github:pull-request — renders and opens a PR adding apps/<name>.yaml and <name>/ to platform-capstone. 4) catalog:register — adds the new service to the Software Catalog.
  3. Because platform-capstone is the repo Argo CD's root Application reconciles from — committing straight to main would deploy an unreviewed service automatically. A PR keeps a human (or a required check) as the last gate, the same principle Part 1's selfHeal lesson taught about never hand-editing the cluster directly.
  4. It's just another folder in platform-capstone (platform-addons/backstage/) plus one more Application in apps/ — the same App-of-Apps pattern from Part 1 that already fans out every other app in this capstone.
  5. Any two of: a scoped GitHub App or PAT rather than a broad-access token; the permission framework turned on so only approved groups can run the template; catalog.rules restricting which entity kinds a location may register; the PR-not-auto-merge design that keeps a human review in the loop.

Part 4 gave the whole capstone a front door: a portal, a catalog entry for ledger, and a golden path proven end to end by scaffolding a second real service with zero hand-written YAML. Continue to Capstone Part 5 — Observability & Operations, where ledger gets the full golden-signals dashboard, alert and break-then-triage treatment — with invoices along for the ride to prove the recipe repeats. Or step back to the full lab track to see how this part fits the rest of the capstone, and revisit Self-Service & Developer Portals and Backstage for the concepts behind what you just built.