background-shape
Golden Paths and Software Templates in Backstage, A Step by Step Guide
October 8, 2025 · 9 min read · by Muhammad Amal programming

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. Inside fetch:template the variable is values. Outside, in scaffolder step inputs, it’s parameters. 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 a properties field on a parameter that’s missing the type key. Run yarn backstage-cli plugin scaffolder validate to surface the exact path.
  • Failed to publish to GitHub: Resource not accessible by integration. The GitHub app powering the integration doesn’t have Administration: Write on 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 Unknown health. The ArgoCD action returned 201 but the manifests in deploy/overlays/prod are malformed, or the destination namespace doesn’t exist and CreateNamespace=true was 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.