Golden Paths and Software Templates in Backstage, A Step by Step Guide
TL;DR — A golden path is a template plus opinions plus automation. In Backstage 1.34, templates are YAML, custom actions are TypeScript, and the win is gluing them to your CI, your IaC, and your service catalog in one click. Skip the wizard-of-choices anti-pattern. Ship a small set of opinionated templates that bake in 80 percent of the decisions.
If you’ve built a portal, golden paths are the second hill to climb. The portal renders a catalog and TechDocs. The templates do the work that actually moves the platform forward, which is the work of letting an engineer ship a new service in twenty minutes with monitoring, CI, deployment, ownership, and docs already wired.
This post walks through how I build templates in production, including the custom actions I always end up writing, the directory layout, and the integration with ArgoCD. It assumes you’ve stood up a portal already. If you haven’t, the previous post on Backstage 1.34 covers that. Here, the focus is the scaffolder and the social contract a golden path encodes.
The defining property of a golden path is that it is shorter, not better. Better paths exist, but require more effort. The golden path gets you a working service in the time it takes to drink a coffee, and the trade-off is that you accept the platform’s opinions. If your template offers eighteen radio buttons, you don’t have a golden path. You have a wizard, and engineers will rightly ignore it.
1. Template Anatomy Under the New Backend
A Backstage software template is a single YAML document with three sections that matter: parameters (the form), steps (the pipeline), and output (what shows up in the portal afterwards). Here’s a complete minimal one:
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: typescript-service
title: TypeScript Backend Service
description: Production-ready TypeScript service on Node.js 22
tags: [recommended, typescript, backend]
spec:
owner: team-platform
type: service
parameters:
- title: Identity
required: [name, owner, system]
properties:
name:
type: string
pattern: '^[a-z][a-z0-9-]{2,30}$'
ui:autofocus: true
ui:help: Lowercase, hyphens, 3-31 chars
owner:
type: string
ui:field: OwnerPicker
ui:options:
allowedKinds: [Group]
system:
type: string
ui:field: EntityPicker
ui:options:
catalogFilter:
kind: System
steps:
- id: template
name: Render skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
owner: ${{ parameters.owner }}
system: ${{ parameters.system }}
- id: publish
name: Create GitHub repo
action: publish:github
input:
repoUrl: github.com?repo=${{ parameters.name }}&owner=acme-engineering
defaultBranch: main
protectDefaultBranch: true
requiredApprovingReviewCount: 1
repoVisibility: internal
topics: [service, typescript, golden-path]
- id: register
name: Register in catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
- id: argo
name: Bootstrap ArgoCD app
action: argocd:create-resources
input:
appName: ${{ parameters.name }}
argoInstance: production
namespace: ${{ parameters.name }}
repoUrl: ${{ steps.publish.output.remoteUrl }}
path: deploy/overlays/prod
output:
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Catalog entity
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}
Two things to notice. First, the parameters use Backstage’s custom UI fields (OwnerPicker, EntityPicker) so the form is grounded in the live catalog rather than freeform text. Second, the steps chain outputs into inputs. publish:github returns repoContentsUrl and remoteUrl, which catalog:register and argocd:create-resources consume.
2. Skeleton Directory Layout
The ./skeleton folder referenced by fetch:template is the heart of the template. It’s a normal directory tree with ${{ values.name }}-style placeholders that get expanded during scaffolding.
skeleton/
+- catalog-info.yaml
+- mkdocs.yml
+- package.json
+- tsconfig.json
+- .github/
| +- workflows/
| +- ci.yml
| +- techdocs.yml
+- src/
| +- index.ts
| +- routes/health.ts
+- deploy/
| +- base/
| | +- deployment.yaml
| | +- service.yaml
| | +- kustomization.yaml
| +- overlays/
| +- prod/
| +- kustomization.yaml
+- docs/
+- index.md
The catalog-info.yaml template should be tight:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
description: ${{ values.description | default('TODO: describe me') }}
annotations:
github.com/project-slug: acme-engineering/${{ values.name }}
backstage.io/techdocs-ref: dir:.
backstage.io/kubernetes-id: ${{ values.name }}
argocd/app-name: ${{ values.name }}
spec:
type: service
lifecycle: production
owner: ${{ values.owner }}
system: ${{ values.system }}
Once the skeleton is rendered, every annotation the platform consumes is already filled in. No human ever needs to remember that the Kubernetes plugin uses backstage.io/kubernetes-id and ArgoCD uses argocd/app-name. That’s what golden paths are for.
3. Writing a Custom Scaffolder Action
The built-in actions handle most cases, but you’ll hit walls. The most common one is “create a Vault policy for this service” or “register the new repo in our internal CMDB”. For those, you write a TypeScript action and register it with the scaffolder plugin.
// plugins/scaffolder-actions/src/actions/vault-policy.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import fetch from 'node-fetch';
export const createVaultPolicyAction = (opts: { vaultAddr: string; tokenSecret: string }) =>
createTemplateAction<{
serviceName: string;
environments: string[];
}>({
id: 'acme:vault:create-policy',
description: 'Creates a Vault policy and AppRole for a service',
schema: {
input: {
type: 'object',
required: ['serviceName', 'environments'],
properties: {
serviceName: { type: 'string', pattern: '^[a-z][a-z0-9-]{2,30}$' },
environments: {
type: 'array',
items: { type: 'string', enum: ['dev', 'staging', 'prod'] },
},
},
},
output: {
type: 'object',
properties: {
roleId: { type: 'string' },
policyName: { type: 'string' },
},
},
},
async handler(ctx) {
const { serviceName, environments } = ctx.input;
const policyName = `svc-${serviceName}`;
const policyHcl = environments
.map(env => `path "secret/data/${env}/${serviceName}/*" { capabilities = ["read"] }`)
.join('\n');
ctx.logger.info(`Creating Vault policy ${policyName}`);
const policyRes = await fetch(`${opts.vaultAddr}/v1/sys/policies/acl/${policyName}`, {
method: 'PUT',
headers: { 'X-Vault-Token': opts.tokenSecret },
body: JSON.stringify({ policy: policyHcl }),
});
if (!policyRes.ok) throw new Error(`Vault policy create failed: ${policyRes.status}`);
const roleRes = await fetch(
`${opts.vaultAddr}/v1/auth/approle/role/${serviceName}`,
{
method: 'POST',
headers: { 'X-Vault-Token': opts.tokenSecret },
body: JSON.stringify({
token_policies: [policyName],
token_ttl: '24h',
token_max_ttl: '72h',
}),
},
);
if (!roleRes.ok) throw new Error(`Vault role create failed: ${roleRes.status}`);
const idRes = await fetch(
`${opts.vaultAddr}/v1/auth/approle/role/${serviceName}/role-id`,
{ headers: { 'X-Vault-Token': opts.tokenSecret } },
);
const idJson = (await idRes.json()) as { data: { role_id: string } };
ctx.output('roleId', idJson.data.role_id);
ctx.output('policyName', policyName);
},
});
Register it with the new backend system through a module:
// plugins/scaffolder-actions/src/module.ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
import { createVaultPolicyAction } from './actions/vault-policy';
export const scaffolderActionsModule = createBackendModule({
pluginId: 'scaffolder',
moduleId: 'acme-actions',
register(reg) {
reg.registerInit({
deps: { scaffolder: scaffolderActionsExtensionPoint, config: coreServices.rootConfig },
async init({ scaffolder, config }) {
scaffolder.addActions(
createVaultPolicyAction({
vaultAddr: config.getString('vault.addr'),
tokenSecret: config.getString('vault.token'),
}),
);
},
});
},
});
In packages/backend/src/index.ts add backend.add(scaffolderActionsModule) and the new action is available to any template as action: acme:vault:create-policy.
4. Designing the Pipeline of Steps
A scaffolder step pipeline isn’t a free-form DAG. It’s a linear list, and order matters because each step’s outputs are inputs to the next. The pattern I converge on, in order, is:
1. fetch:template <- render skeleton
2. acme:vault:policy <- create secrets infrastructure
3. publish:github <- create the repo (irreversible side effect)
4. acme:cmdb:register <- register in internal CMDB
5. catalog:register <- ingest the catalog-info.yaml
6. argocd:create-resources <- bootstrap deployment
Put the irreversible side effects late. If fetch:template fails because of a typo in a placeholder, you don’t want to have already created a GitHub repo you have to clean up. The Vault step is the awkward one. I create the policy before the repo exists, so if the repo creation fails, I have an orphan policy. The cleanup is a separate scheduled job that prunes Vault roles without a matching catalog entity older than 24 hours.
5. Wiring ArgoCD Onboarding
The argocd:create-resources action ships with the Roadie ArgoCD plugin and works against ArgoCD 2.13. Configure it in app-config.yaml:
argocd:
username: ${ARGOCD_USERNAME}
password: ${ARGOCD_PASSWORD}
appLocatorMethods:
- type: 'config'
instances:
- name: production
url: https://argocd.acme.internal
token: ${ARGOCD_AUTH_TOKEN}
The action creates an Application CRD via the ArgoCD API. For a freshly templated service, the manifest it generates looks like:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: payments-api
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme-engineering/payments-api.git
targetRevision: main
path: deploy/overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: payments-api
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
By the time the scaffolder finishes, ArgoCD is watching the new repo’s deploy/overlays/prod path. The first commit (which is the skeleton’s commit) gets synced automatically and a Kubernetes namespace plus deployment come up.
user -> Backstage -> scaffolder steps
|
+-------------+-------------+--------+
v v v v
GitHub Vault Catalog ArgoCD
| |
+---> repo content ---> kubectl <----+
6. Testing Templates Without Breaking Production
Templates are code. They need a test loop. The cheapest way is the scaffolder’s dry-run mode, which renders the skeleton and reports what each step would do without making real API calls.
yarn backstage-cli plugin scaffolder dry-run \
--template ./templates/typescript-service/template.yaml \
--values name=test-svc,owner=group:default/team-platform,system=system:default/core
For deeper testing, write Jest tests against the custom actions:
// plugins/scaffolder-actions/src/actions/vault-policy.test.ts
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { createVaultPolicyAction } from './vault-policy';
describe('vault-policy', () => {
it('creates a policy with environment-scoped paths', async () => {
const action = createVaultPolicyAction({
vaultAddr: 'http://vault-mock',
tokenSecret: 'test-token',
});
const ctx = createMockActionContext({
input: { serviceName: 'payments', environments: ['dev', 'prod'] },
});
// mock fetch with nock here
await action.handler(ctx);
expect(ctx.output).toHaveBeenCalledWith('policyName', 'svc-payments');
});
});
Common Pitfalls
- The wizard anti-pattern. Twelve form fields on a template is a sign that the platform team is hiding from a decision. Pick the default, ship it as the golden path, and let people who need the other option file a ticket. Aim for three fields max.
- Putting the GitHub repo creation first. If a downstream step fails, you’ve left a dangling empty repo with no way for the scaffolder to roll it back. Render the skeleton first, do all the safe API calls second, do the irreversible publish last.
- Hardcoded placeholders.
${{ parameters.owner }}looks the same as${{ values.owner }}until it doesn’t. Insidefetch:templatethe variable isvalues. Outside, in scaffolder step inputs, it’sparameters. Mixing them produces silent empty strings. - No retention policy on tasks. The scaffolder task store grows forever by default. After six months you have a hundred thousand task records in Postgres and the task list page times out. Configure
scaffolder.taskRetention: PT720H(30 days) and a cleanup job.
Troubleshooting
Template entity has invalid spec. The template YAML schema is strict in 1.34. The most common cause is apropertiesfield on a parameter that’s missing thetypekey. Runyarn backstage-cli plugin scaffolder validateto surface the exact path.Failed to publish to GitHub: Resource not accessible by integration. The GitHub app powering the integration doesn’t haveAdministration: Writeon the org. Without it, the app can create repos in the org but cannot configure branch protection, and the action errors out late.- ArgoCD Application created but stuck in
Unknownhealth. The ArgoCD action returned 201 but the manifests indeploy/overlays/prodare malformed, or the destination namespace doesn’t exist andCreateNamespace=truewas missed. Check the ArgoCD UI directly, the scaffolder doesn’t poll for sync status by default.
Wrapping Up
Golden paths are the most leveraged thing a platform team builds. Every template that gets used a hundred times pays back the day you wrote it many times over. Keep the templates small, opinionated, and tested, and you’ll find yourself shipping new ones whenever a team has a recurring need.
The official scaffolder docs cover every action and parameter type. The next post in this series gets into custom plugin development beyond scaffolder actions.