Module 06 · Golden paths and docs · 60 min · You are: Platform team
Golden paths with software templates
In this module
- You learn: How a template is built: parameters (a form), steps (actions run on the backend) and outputs, plus the template editor's dry run.
- You do: Add the platform's template and its skeleton, register it, dry-run it in the editor, then create a real
cartservice from it. - You end with: The portal offers one template, Node.js service. A
cartrepo exists on GitHub, created by the template and already in the catalog, owned by team-checkout.
Every team that starts a service by copying an old repo copies its mistakes too. A template makes the right way the easy way: the owner, the system and the docs are there from the first commit, and the service is in the catalog before anyone forgets. The platform team owns the template; product teams only fill in a form.
Open this module in the interactive course to track progress.
Concepts
- Parameters
- JSON Schema with
ui:hints. Each property becomesparameters.<name>. Pickers likeOwnerPickerread the catalog. - Steps and actions
- Each step runs one action on the backend:
fetch:templaterenders files,publish:githubcreates a repo,catalog:registeradds it. Outputs feed later steps. - Skeleton
- Files with
${{ values.x }}placeholders, rendered byfetch:templatewith Nunjucks. - Dry run
- The template editor runs the steps without side effects and shows the files, log and output. Actions that do not support dry run are skipped.
Files to know
Open them in your editor or on GitHub; the bullets say what to look at.
portal/templates/node-service/template.yaml(reference, your repo): The golden path, owned by the platform team.portal/templates/node-service/skeleton/catalog-info.yaml(reference, your repo): The entity every new service starts with.- Repo and docs linked from the first commit L6–9
Steps
1. Add the template
Copy the skeleton from the reference; write the template yourself so you read every part.
mkdir -p ~/work/portal/templates/node-service && cp -R ~/work/backstage-workshop/portal/templates/node-service/skeleton ~/work/portal/templates/node-service/
Create ~/work/portal/templates/node-service/template.yaml
# Owned by the platform team. The golden path for a new Node.js service.
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: node-service
title: Node.js service
description: A new Node.js service with docs, an owner and a place in the catalog.
tags: [nodejs, recommended]
spec:
owner: group:default/platform
type: service
# One page of the form per item. Each property becomes parameters.<name>.
parameters:
- title: About the service
required: [name, owner, system]
properties:
name:
title: Name
type: string
description: Lowercase, dashes allowed. Also the repository name.
ui:field: EntityNamePicker
ui:autofocus: true
description:
title: Description
type: string
owner:
title: Owner
type: string
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
spec.type: team
system:
title: System
type: string
ui:field: EntityPicker
ui:options:
catalogFilter:
kind: System
- title: Where the code goes
required: [repoUrl]
properties:
repoUrl:
title: Repository
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts: [github.com]
allowedOwners: [YOUR_GITHUB_USER]
# Steps run in order on the backend. Each one can read earlier outputs.
steps:
- id: fetch
name: Render the skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
ownerTitle: ${{ parameters.owner }}
system: ${{ parameters.system }}
repoUrl: ${{ parameters.repoUrl }}
- id: publish
name: Create the repository
action: publish:github
input:
repoUrl: ${{ parameters.repoUrl }}
description: ${{ parameters.description }}
defaultBranch: main
repoVisibility: public
- id: register
name: Add it to the catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps['publish'].output.remoteUrl }}
- title: Open in catalog
icon: catalog
entityRef: ${{ steps['register'].output.entityRef }}
2. Register it, drop the example
Templates are entities too. Only the platform's own location may add them.
Change in ~/work/portal/app-config.yaml
Under catalog.locations.
- # Local example template
- - type: file
- target: ../../examples/template/template.yaml
- rules:
- - allow: [Template]
+ # Platform-owned software templates (module 06).
+ - type: file
+ target: ../../templates/node-service/template.yaml
+ rules:
+ - allow: [Template]
Save. Expect node-service only:
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" "localhost:7007/api/catalog/entities?filter=kind=template" | jq -r '.[].metadata.name'
3. Dry-run it in the template editor
Test a template before anyone uses it. The editor reads the folder from your disk, so it needs Chrome or Edge.
1. Open Create, then the menu (⋮) at the top right, then Template Editor.
2. Load Template Directory and pick ~/work/portal/templates/node-service.
3. Fill the form: name dry-run-test, owner Team Checkout, system shop, repository owner YOUR_GITHUB_USER, name dry-run-test.
4. Click Create. Nothing is created: it is a dry run.
5. Files shows the rendered skeleton: open catalog-info.yaml and find owner: group:default/team-checkout and system: system:default/shop. Log shows each step. Output shows the links.
4. Ship it
cd ~/work/portal && git status --short
Propose it as a pull request. Put it on its own branch and push it:
git switch -c template-node-service && git add -A && git commit -qm "Templates: Node.js service golden path"
git push -qu origin HEAD
Open the pull request. It prints a link:
gh pr create --fill
Open that link in your browser. Files changed shows exactly your diff.
Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:
gh pr merge --squash --delete-branch
Either way, bring your copy up to date:
git switch -q main && git pull -q
5. As team-checkout: create a service
Now for real. publish:github uses the token in .env to create the repo.
1. Create → Node.js service → Choose.
2. Name cart, description The shopping cart, owner Team Checkout, system shop. Next.
3. Repository owner YOUR_GITHUB_USER, name cart. Review, then Create.
4. Watch the three steps turn green, then click Open in catalog.
The repo exists. Expect cart and its description:
gh repo view YOUR_GITHUB_USER/cart --json name,description
And the catalog knows its owner and system. Expect ownedBy group:default/team-checkout and partOf system:default/shop:
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/catalog/entities/by-name/component/default/cart | jq -r '.relations[] | .type + " " + .targetRef'
Simplified for the course
The template creates public repos under your user. In a company, it creates private repos in the organisation, adds branch protection and CODEOWNERS, and the template itself has tests that run a dry run in CI.
Break it on purpose: A tidy-up in the template
Someone on the platform team removes a line from the template's fetch step that "looked unused". The template still loads and runs.
You practise: using the dry run to find a value that does not reach the skeleton.
1. Break it
Delete from ~/work/portal/templates/node-service/template.yaml
In the fetch step's values.
- system: ${{ parameters.system }}
Dry-run the template again in the editor (reload the directory first) with the same values.
2. What you see
Expected: system: system:default/shop in the rendered catalog-info.yaml. You get: system: with nothing after it. The form still asks for a system; the template does not complain.
3. Fix it yourself
Find where the system value gets lost between the form and the skeleton, and fix it. Prove it with a dry run.
Hint 1
The skeleton reads values.system. Where do values come from?
Hint 2
Compare the parameters with the values of the fetch step: one is missing.
The fix
Add to ~/work/portal/templates/node-service/template.yaml in the fetch step's values, after ownerTitle
system: ${{ parameters.system }}
Dry-run again: system: system:default/shop is back.
After you fixed it: the five questions
- What failed?
- A form field was no longer passed to
fetch:template, so the skeleton rendered an empty value. - How was it detected?
- In the dry run's Files tab. In production it would show up as services without a system.
- Who responds?
- The platform team: they own the template.
- How is it corrected?
- Pass the value again and dry-run.
- How is it prevented?
- Run a dry run of every template in CI and assert key fields of the rendered
catalog-info.yaml.
Quiz
- Where do template steps run?
- On the backend, with the backend's credentials, one action per step
- In the user's browser, with their GitHub session
- In GitHub Actions in the new repo
- What does a dry run in the template editor do?
- Creates the repo, then deletes it again
- Runs the steps without side effects and shows the rendered files, log and output
- Checks only that the YAML is valid
- Why does the owner field use
OwnerPickerwith acatalogFilter?- It makes the form load faster
- GitHub requires it to create a repo
- It only offers teams that exist in the catalog, so a typo like
team-chekoutis impossible
Answers
- On the backend, with the backend's credentials, one action per step The scaffolder backend runs the actions; that is why
publish:githubuses the integration token. - Runs the steps without side effects and shows the rendered files, log and output Actions that support dry run simulate; others are skipped.
- It only offers teams that exist in the catalog, so a typo like
team-chekoutis impossible Pickers read the catalog. That prevents the module 03 break at the source.
Field note
The first template a platform team ships should be boring: the service type most teams create most often. A great template nobody needs teaches the company that templates are a toy.