- Published on
Backstage Scaffolder — Turning Golden Paths into Code
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — The Organization Where a New Service Takes Two Weeks
- How the Scaffolder Works
- Dissecting template.yaml — parameters, steps, output
- Built-in Actions — The Building Blocks of Composition
- Nunjucks Templating — The skeleton Directory
- Custom Action Development — A Jira Ticket Example
- Input Validation and UI Customization
- Injecting Organizational Standards — The Skeleton Is the Policy
- Template Testing, Versioning, and Dry-run
- Governance — Who Builds Templates
- Measuring Golden Paths — Adoption Rate and Lead Time
- Anti-patterns — Build It This Way and Nobody Uses It
- Checklist
- Closing
- References
Introduction — The Organization Where a New Service Takes Two Weeks
Let us list what it actually takes to stand up one new microservice: repository creation, boilerplate code, a CI pipeline, a Dockerfile, Kubernetes manifests or a Helm chart, monitoring dashboards, alert rules, logging configuration, secret management wiring, and catalog registration. At every step, "copy what we did last time" happens, the copy source differs per team, and the standard deviation between services grows over time. In some organizations this process takes two weeks, and most of those two weeks is spent digging through wikis and asking other teams questions.
The Golden Path is the answer to this problem. The term, coined at Spotify, means "a supported, proven path with minimal friction." The crucial point is that it works by incentive, not enforcement. A golden path is not a fence; it is a paved road. You can leave it, but if the paved road is fast and comfortable enough, there is no reason to.
The Backstage Scaffolder (Software Templates) is the tool that turns golden paths into executable code. This article covers everything needed to operate the Scaffolder at production level — from the structure of template.yaml to custom action development, governance, and measurement. Remember that the catalog from Part 1 is a prerequisite: the Scaffolder's output always flows into the catalog.
How the Scaffolder Works
The Scaffolder is a backend plugin, and template execution is handled in units called tasks.
Developer Scaffolder UI Scaffolder Backend
| | |
|--- pick template ------>| |
|--- fill form ---------->| |
| |--- create task --------->|
| | |
| | [steps run in order] |
| | 1. fetch:template |
| | 2. publish:github |
| | 3. catalog:register |
| | |
|<-- live log stream -----|<-- per-step logs --------|
|<-- output links --------|<-- done -----------------|
| | |
v v v
New repo + CI + catalog registration done (within minutes)
A template is itself a catalog entity (kind: Template). That means templates have owners, are auto-registered via discovery, and fall under catalog governance.
Dissecting template.yaml — parameters, steps, output
Let us look at a complete practical template first and then dissect it part by part.
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: springboot-service
title: Spring Boot Microservice
description: Creates a Spring Boot service with organizational standards built in
tags:
- java
- spring-boot
- recommended
spec:
owner: group:default/platform-team
type: service
parameters:
- title: Basic service information
required:
- name
- description
- owner
properties:
name:
title: Service name
type: string
pattern: '^[a-z0-9-]+$'
maxLength: 40
description: Lowercase letters, digits, and hyphens only
ui:autofocus: true
description:
title: Description
type: string
owner:
title: Owning team
type: string
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
spec.type: team
- title: Technical options
properties:
javaVersion:
title: Java version
type: string
default: '21'
enum: ['17', '21']
enableKafka:
title: Include Kafka consumer
type: boolean
default: false
steps:
- id: fetch
name: Render skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
javaVersion: ${{ parameters.javaVersion }}
enableKafka: ${{ parameters.enableKafka }}
- id: publish
name: Create GitHub repository
action: publish:github
input:
repoUrl: github.com?owner=acme-corp&repo=${{ parameters.name }}
defaultBranch: main
repoVisibility: internal
protectDefaultBranch: true
requireCodeOwnerReviews: true
- id: register
name: Register in catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Open repository
url: ${{ steps['publish'].output.remoteUrl }}
- title: View in catalog
icon: catalog
entityRef: ${{ steps['register'].output.entityRef }}
parameters is a JSON Schema based form definition. Each item in the array becomes one page of the wizard, and JSON Schema validations like pattern, enum, and maxLength run on both client and server. Fields with the ui: prefix are UI extensions based on react-jsonschema-form; ui:field: OwnerPicker renders a dedicated widget that searches Group entities in the catalog.
steps is the list of actions executed in order. The output of each step can be referenced by later steps. The expression syntax is Nunjucks-based, using the dollar sign with double curly braces as shown in the code block examples.
output defines the links shown on the completion screen. The convention is to provide direct navigation to the new repository and the catalog page.
Built-in Actions — The Building Blocks of Composition
The most frequently used built-in actions:
| Action | Role | Notes |
|---|---|---|
| fetch:template | Fetches the skeleton and renders it with Nunjucks | The heart of the golden path |
| fetch:plain | Copies files without rendering | Binaries, files used as-is |
| publish:github | Creates the repo + pushes code | Includes branch protection options |
| publish:github:pull-request | Opens a PR against an existing repo | For injecting standards into existing code |
| catalog:register | Registers an entity in the catalog | Conventionally the last step |
| catalog:write | Generates a catalog-info.yaml file | Added to fetch output |
| fs:rename / fs:delete | Manipulates files in the working directory | Conditional file cleanup |
| debug:log | Debug output | During template development |
The full list of actions available on an installed instance, along with their input schemas, can be found at the portal path /create/actions. Making a habit of checking this page before writing a template saves a lot of trial and error.
Publish actions for GitLab, Azure DevOps, and Bitbucket are available as separate modules, so the same pattern applies even if your VCS is not GitHub.
Nunjucks Templating — The skeleton Directory
The fetch:template action renders every text file in the skeleton directory through the Nunjucks template engine. An example directory structure:
templates/springboot-service/
├── template.yaml
└── skeleton/
├── catalog-info.yaml
├── README.md
├── Dockerfile
├── .github/
│ └── workflows/
│ └── ci.yaml
├── k8s/
│ ├── deployment.yaml
│ └── service.yaml
└── src/main/java/...
Files inside the skeleton use substitution variables. For example, the skeleton catalog-info.yaml is written like this:
# skeleton/catalog-info.yaml (Nunjucks template)
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
description: ${{ values.description | dump }}
annotations:
github.com/project-slug: acme-corp/${{ values.name }}
backstage.io/techdocs-ref: dir:.
spec:
type: service
lifecycle: experimental
owner: ${{ values.owner }}
Conditionals and loops are available too. A build.gradle fragment that adds or removes dependencies based on the Kafka option:
// skeleton/build.gradle (Nunjucks conditional)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
{%- if values.enableKafka %}
implementation 'org.springframework.kafka:spring-kafka'
{%- endif %}
}
The same substitution syntax works in file and directory names, so structures where the service name appears in a path — like package directories — can be expressed as well. One trap to know: files that themselves contain double-curly-brace syntax, such as GitHub Actions workflow files, can be misinterpreted by Nunjucks. In that case, wrap the content in raw blocks or exclude the paths from rendering with the copyWithoutTemplating option of fetch:template.
- id: fetch
action: fetch:template
input:
url: ./skeleton
copyWithoutTemplating:
- .github/workflows/*.yaml # copy Actions workflows without substitution
values:
name: ${{ parameters.name }}
Custom Action Development — A Jira Ticket Example
When built-in actions are not enough, you write a custom action in TypeScript. Here is an action that creates a tracking ticket in Jira when a service is created.
// plugins/scaffolder-backend-module-acme/src/actions/createJiraTicket.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
export const createJiraTicketAction = () => {
return createTemplateAction<{
projectKey: string;
summary: string;
description: string;
}>({
id: 'acme:jira:create-ticket',
description: 'Creates a Jira ticket for tracking a new service',
schema: {
input: {
type: 'object',
required: ['projectKey', 'summary'],
properties: {
projectKey: { type: 'string', title: 'Jira project key' },
summary: { type: 'string', title: 'Ticket summary' },
description: { type: 'string', title: 'Ticket body' },
},
},
output: {
type: 'object',
properties: {
ticketUrl: { type: 'string' },
},
},
},
async handler(ctx) {
const { projectKey, summary, description } = ctx.input;
ctx.logger.info(`Creating Jira ticket in project ${projectKey}`);
const response = await fetch('https://jira.acme.io/rest/api/2/issue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.JIRA_TOKEN}`,
},
body: JSON.stringify({
fields: {
project: { key: projectKey },
summary,
description,
issuetype: { name: 'Task' },
},
}),
});
if (!response.ok) {
throw new Error(`Jira API error: ${response.status}`);
}
const issue = await response.json();
ctx.output('ticketUrl', `https://jira.acme.io/browse/${issue.key}`);
},
});
};
In the new backend system, the action is registered as a module.
// plugins/scaffolder-backend-module-acme/src/module.ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
import { createJiraTicketAction } from './actions/createJiraTicket';
export const scaffolderModuleAcme = createBackendModule({
pluginId: 'scaffolder',
moduleId: 'acme-actions',
register(reg) {
reg.registerInit({
deps: { scaffolder: scaffolderActionsExtensionPoint },
async init({ scaffolder }) {
scaffolder.addActions(createJiraTicketAction());
},
});
},
});
The template steps can now invoke it as action: acme:jira:create-ticket. Custom actions are the key instrument for building golden paths unique to your organization. Registering with the in-house secret management system, requesting internal DNS, applying cost tags — every "procedure that only exists in our company" is an action candidate.
Input Validation and UI Customization
Form quality directly affects template adoption. There are three levels of mechanisms.
1) JSON Schema validation. pattern, minLength, and enum are table stakes. For values that must be globally unique, like service names, a regex is not enough — a custom field extension that queries the catalog API and blocks duplicates up front works better.
2) Built-in custom fields. OwnerPicker (group selection), EntityPicker (entity selection), and RepoUrlPicker (repository location selection) are used most. RepoUrlPicker can pin the organization via allowedOwners to prevent mistakes.
repoUrl:
title: Repository location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
allowedOwners:
- acme-corp
3) Custom field extensions you build yourself. By registering a React component and a validation function on the frontend, form fields can be fully customized — for example, a field that searches internal cost-center codes from an ERP API and lets the user pick one.
Injecting Organizational Standards — The Skeleton Is the Policy
The value of a golden path comes from the contents of the skeleton, not the template engine. Bake into the skeleton everything a new service must already have at the moment it is born:
- CI pipeline: a workflow including build, tests, static analysis (SonarQube), container image scanning, and SBOM generation
- Observability: metrics endpoint exposure, structured logging configuration, automatic standard dashboard creation (including dashboard provisioning code)
- Security defaults: secrets only via external secret manager references, non-root container execution, a NetworkPolicy included by default
- Operational standards: readiness/liveness probes, resource requests/limits, PodDisruptionBudget, standard labels
- Docs and catalog: a TechDocs skeleton (mkdocs.yml + docs directory) and catalog-info.yaml
Done this way, you no longer need to say "follow the standards" — following the standards becomes the easiest path. Conversely, a template with a thin skeleton is not a golden path; it is just a repository generator.
Template Testing, Versioning, and Dry-run
Templates are software too, so they need testing and version control.
Dry-run: In the Backstage UI template editor (/create/edit) you can load a template and preview the rendered result without actually creating a repository. It is the fastest loop for checking output directories against form inputs.
Validation in CI: Automate at least the following in the template repository PR pipeline.
# .github/workflows/template-ci.yaml
name: template-ci
on:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate template.yaml schema
run: npx @roadiehq/backstage-entity-validator validate template.yaml
- name: Skeleton render smoke test
run: |
# Render the skeleton with sample values, then
# verify that the output actually builds
./scripts/render-and-build.sh sample-values.json
Verifying that the rendered output actually builds and its tests pass is essential. The experience of "a service created from the template fails its very first build" destroys golden-path credibility in one stroke.
Versioning: Tag the template repository with semantic versions and register templates in production Backstage from tags/releases. Also keep in mind that template changes do not retroactively apply to previously generated services. To propagate standard changes to existing services, you need a bulk PR campaign based on the publish:github:pull-request action or separate automation.
Governance — Who Builds Templates
The template ownership model must balance between two extremes.
| Model | Pros | Cons |
|---|---|---|
| Platform team monopoly | Consistent quality, standard control | Bottleneck, slow response to field needs |
| Fully open | Fast spread, close to the field | Quality variance, duplicate template sprawl |
The recommended middle ground is an "open + review" model. Anyone can propose a template, but to be exposed in the official catalog with the recommended tag, it must pass platform team review. Publish the review criteria as a checklist: whether skeleton standards are included, whether CI validation exists, whether an owner group is assigned, and the level of documentation. And enforce an owner per template (templates are catalog entities, so the governance from Part 1 applies directly) so that abandoned templates never remain anonymous.
Measuring Golden Paths — Adoption Rate and Lead Time
A golden path you do not measure cannot be improved. Track two core metrics.
Adoption rate: the share of newly created services that came through a template within a given period. It can be computed by cross-referencing Scaffolder task history against new Component registrations in the catalog. A low adoption rate is a signal that the template diverges from field needs.
New service lead time: the time from "decision to build" to "first production deployment." If you measure a baseline before introducing templates, you can report the effect quantitatively. Two weeks becoming two hours is genuinely not rare.
Useful secondary metrics include the template execution failure rate (from task logs), usage distribution per template (identifying unused templates), and the standards compliance rate at 30 days after creation (whether generated services still retain the skeleton CI/observability configuration).
Anti-patterns — Build It This Way and Nobody Uses It
Too many options. A template with twenty parameters merely relocates the friction of choice. The essence of a golden path is making decisions on behalf of the user. The more options, the more decision burden returns to the user. Treat more than five options as a signal the template should be split. Two templates — "Spring Boot template" and "Quarkus template" — beat one "Java service template with a three-way framework option."
Abandoned templates. A template untouched for six months is likely already out of line with organizational standards: stale dependencies, deprecated CI syntax, changed security policies. The moment a freshly generated service trips the security scanner, trust is gone. Attach a dependency-update bot to the template repository too, and run a recurring pipeline that re-validates skeleton output builds every quarter.
One-shot thinking. Viewing templates only as "a tool used once at creation time." Mature organizations use the Scaffolder for day-2 operations as well: adding monitoring to an existing service, generating library migration PRs, requesting infrastructure resources. Templatize those and the portal evolves from "service generator" into "self-service hub."
Undocumented templates. If there is no explanation of what the template creates, what it does not create, and what to do after generation, users hesitate to run it. State the output inventory and post-creation steps in the template description and README.
Checklist
- Templates are version-controlled in a dedicated repository (tag-based registration)
- template.yaml schema validation exists in the PR pipeline
- Automated build/test smoke tests of rendered skeleton output
- CI, observability, security, and operational standards all built into the skeleton
- catalog-info.yaml and a TechDocs skeleton included in the output
- Five or fewer parameters, with sensible defaults
- Input mistakes blocked via OwnerPicker / RepoUrlPicker
- Files containing double curly braces (e.g. workflows) handled via copyWithoutTemplating
- An owner group per template, with a documented review process
- Measurement in place for adoption rate and new service lead time
- Quarterly template re-validation (dependencies, security policy alignment) pipeline
- A roadmap for day-2 operation templates
Closing
Technically, the Scaffolder is a simple tool: it draws forms, renders files, creates repositories, and registers entities. Organizationally, it is a powerful lever. Organizational standards become executable code instead of documents, and the day-one quality of every new service is leveled up to the organization's best. Three things matter most: pour standards generously into the skeleton, test and version templates like software, and measure adoption to confirm the golden path is a road people actually walk.
Part 3 covers TechDocs, plugin development, and the overall checklist for operating Backstage in production.
References
- Backstage Software Templates documentation
- Writing Templates guide
- Builtin Actions list
- Writing Custom Actions guide
- Writing Custom Field Extensions guide
- Input Examples (parameter form examples)
- Nunjucks template engine official docs
- JSON Schema official site
- Spotify Engineering — How We Use Golden Paths
- CNCF Backstage project page