Why the Scaffolder Is a Composition of Actions — and Why TechDocs Uses mkdocs
In one line
A Backstage software template has three layers: a form (parameters) → a list of tasks (steps) → a result (output), and each step calls a reusable action. Thanks to this modular structure, different organizations can build their own golden paths from the same parts.
Why this was needed
The first attempt to automate "create a new service" is usually a shell script. But if you list what that script does, it looks like this.
- Take input (name, owning team, language, deployment environment)
- Validate that the input follows the rules (does the name follow DNS rules?)
- Fetch the skeleton from the template repository and substitute values
- Create a new Git repository and push
- Set up CI
- Register it in the catalog
- Show the result link to the person
When you build it as a script, step 2 (validation) gets done sloppily, steps 4–6 need a token so credentials end up on the developer's machine, and step 7 does not exist. And when you build a script for another language, you copy steps 1–2 and 4–7 wholesale.
The scaffolder breaks this structure apart. The input definition becomes a JSON Schema, each task becomes an action, and the result becomes the output. Then the "Node service template" and the "Python service template" differ only in step 1, fetching the skeleton, and use the same actions for the rest.
How it works
The three layers of a Template manifest
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: node-service
title: Node.js 서비스
spec:
owner: group:team-platform
type: service
parameters: # ← 사용자에게 보여 줄 폼 (JSON Schema)
- title: 기본 정보
required: [name, owner]
properties:
name:
type: string
pattern: '^[a-z0-9-]+$'
owner:
type: string
ui:field: OwnerPicker
steps: # ← 실제로 하는 일
- id: fetch
name: 뼈대 가져오기
action: fetch:template
- id: publish
name: 저장소 만들기
action: publish:github
- id: register
name: 카탈로그 등록
action: catalog:register
output: # ← 끝나고 보여 줄 것
links:
- title: Repository
parameters is a JSON Schema. type, required, pattern, and enum work as they are, so wrong input is blocked at the form stage. On top of this, Backstage-specific keys with the ui: prefix specify widgets — things like OwnerPicker (choose from the catalog's list of Groups), RepoUrlPicker (a combination of host/organization/repository name), and EntityPicker. These widgets matter because they eliminate free text. If you let people type the owner by hand, an owner with a typo gets into the catalog.
Each item of steps has id, name, action, and input. The representative actions are these.
| Action | What it does |
|---|---|
fetch:template |
Fetches the skeleton directory, substitutes variables, and unpacks it into the workspace |
fetch:plain |
Fetches files as they are, without substitution |
publish:github / publish:gitlab |
Creates a new repository and pushes the workspace contents |
catalog:register |
Registers the generated catalog-info.yaml in the catalog |
fs:rename, fs:delete |
Manipulate files in the workspace |
Values are passed between actions with template expressions. Form input is referenced as parameters, and the result of an earlier step as steps.<id>.output.<필드> (the placeholders are the step id and the output field name). The output of publish:github includes remoteUrl and repoContentsUrl, and catalog:register returns the reference of the registered entity.
output is the links and text shown to the user after the task ends. It looks trivial, but it plays a big part in developer experience — if five seconds later all you see is "Created" and nothing says where to go next, people start searching again.
What matters here is where the credentials are. What runs publish:github is not the user's browser but the Backstage backend. The token is on the server, and the user cannot see it. That is why "self-service through the portal" is safer than "handing out tokens to everyone."
TechDocs and docs-as-code
TechDocs uses mkdocs. Why mkdocs? Because the documentation source is just Markdown files, the configuration is a single mkdocs.yml, and the result is static files that can be hosted anywhere. Docs-as-code holds up without adopting a heavy documentation platform.
The flow works like this.
- Put
mkdocs.ymlanddocs/index.mdin the service repository. - Attach the annotation
backstage.io/techdocs-ref: dir:.to the entity — it means "this entity's documentation is in the same directory of this repository." - The built static result is stored in storage and rendered in the portal's Docs tab.
There are two strategies for when to build. Local build (the portal builds on request) is easy to set up but slow and unsuitable at large scale. External build (CI builds and uploads to object storage) is the recommended approach in production. CBA asks about this distinction.
The core value of docs-as-code is worth repeating. If documentation goes through the same repository, the same PR, and the same review as the code, the chance of drift drops sharply. Documentation that lives in a separate wiki goes wrong within three months, and wrong documentation is worse than no documentation.
What it looks like in the field
The list of incidents in the author's homelab proves why scaffolding is needed. Gateway API needed CRD v1.6.1, and with v1.2, tlsroutes and referencegrants were not v1, so the Cilium gateway controller refused to start. KubeVirt had a defect in the containerDisk path and had to be worked around through the DataVolume (PVC) path. GPU Operator had an incident in the containerd runtime configuration.
These pieces of knowledge are traps that, once stepped on, there is no reason to step on again. If they stay in people's heads, the next person steps on them the same way, and if you harden them into the template's skeleton and documentation, nobody does. It is an underestimate to think the value of the scaffolder is "reducing typing." The real value is making the verified path the default.
One more. On this cluster, at kubeadm init the --control-plane-endpoint was set not to a VIP or DNS name but to the first node's physical IP. Even after the control plane was later expanded to three nodes, that value means that if the first node dies, API access is cut off. The author wrote, "If I did it again from the beginning, I would put in an indirect address." It means there are values whose initial choice is extremely painful to change later, and such values are exactly what should be baked into a template's defaults.
What you will do in the next lab
In /root/cba-template/, you write a Template manifest — the JSON Schema of parameters, the actions and inputs of steps, and the links of output. Next you attach the techdocs annotation to mkdocs.yml, docs/index.md, and the skeleton's catalog-info.yaml. Finally you apply the manifests this template would generate to a real cluster and verify the result.