DevOpsil

Implementing Internal Developer Platform Golden Paths With Backstage Software Templates

Amara OkaforAmara Okafor8 min read

Implementing Internal Developer Platform Golden Paths with Backstage Software Templates

If you've ever watched a new developer spend three days just getting a service scaffolded, configured, and deployed to a non-production environment, you understand why Golden Paths exist. Backstage Software Templates are one of the most effective tools for implementing them — but only if you do it right.

Let me walk you through how to build Golden Paths that developers actually want to use, with the security guardrails baked in from day one.

What's a Golden Path, Really?

A Golden Path isn't a mandate. It's the path of least resistance to doing the right thing. When you build a Backstage template that scaffolds a new service with SAST configured, a CODEOWNERS file in place, container image scanning in the pipeline, and secrets management already wired up — you've made secure-by-default easier than the alternative.

The goal is to eliminate the blank canvas problem. Developers shouldn't have to make 47 security-adjacent decisions before writing their first line of business logic.

Setting Up Your First Template

Backstage templates live in your catalog as Template kind entities. Here's the skeleton of a production-ready one:

# template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: nodejs-service-template
  title: Node.js Microservice
  description: Scaffolds a production-ready Node.js service with security defaults
  tags:
    - nodejs
    - microservice
    - golden-path
spec:
  owner: platform-engineering
  type: service

  parameters:
    - title: Service Information
      required:
        - name
        - description
        - owner
      properties:
        name:
          title: Service Name
          type: string
          pattern: '^[a-z][a-z0-9-]{2,30}$'
          description: Lowercase alphanumeric, hyphens allowed
        description:
          title: Description
          type: string
          maxLength: 200
        owner:
          title: Owner Team
          type: string
          ui:field: OwnerPicker
          ui:options:
            allowedKinds:
              - Group

    - title: Infrastructure Options
      properties:
        environment:
          title: Target Environment
          type: string
          enum:
            - development
            - staging
          default: development
        enableDatabase:
          title: Requires Database?
          type: boolean
          default: false

  steps:
    - id: fetch-template
      name: Fetch Base Template
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}
          description: ${{ parameters.description }}
          enableDatabase: ${{ parameters.enableDatabase }}

    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        allowedHosts: ['github.com']
        description: ${{ parameters.description }}
        repoUrl: github.com?owner=your-org&repo=${{ parameters.name }}
        defaultBranch: main
        repoVisibility: internal
        requireCodeOwner: true
        deleteBranchOnMerge: true
        squashMergeAllowed: true

    - id: register
      name: Register in 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
        url: ${{ steps['register'].output.entityRef }}

A few things worth noting here. The pattern on the service name enforces consistent naming conventions — a small thing that prevents a massive headache at scale. The OwnerPicker ensures every service has a real team attached to it, not just "platform" as a dumping ground. And requireCodeOwner: true means we're generating a CODEOWNERS file automatically from the template skeleton.

The Skeleton Directory: Where the Magic Happens

Your template skeleton is where you encode your security and operational opinions. Here's what a security-conscious skeleton structure looks like:

skeleton/
├── .github/
│   ├── CODEOWNERS
│   ├── workflows/
│   │   ├── ci.yaml
│   │   └── security-scan.yaml
│   └── PULL_REQUEST_TEMPLATE.md
├── src/
│   └── index.ts
├── Dockerfile
├── catalog-info.yaml
├── .snyk
└── package.json

The CI workflow baked into the skeleton should be non-negotiable. Here's a minimal but meaningful security-scan.yaml:

# skeleton/.github/workflows/security-scan.yaml
name: Security Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  dependency-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Snyk to check for vulnerabilities
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

  container-scan:
    runs-on: ubuntu-latest
    needs: dependency-scan
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t ${{ '${{ github.repository }}' }}:${{ '${{ github.sha }}' }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ '${{ github.repository }}' }}:${{ '${{ github.sha }}' }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

      - name: Upload Trivy scan results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: 'trivy-results.sarif'

Every service born from this template gets dependency scanning and container image scanning on day one. No configuration required from the developer. That's the Golden Path paying off.

Handling Secrets the Right Way

One of the most common template mistakes I see is leaving secrets management as an exercise for the developer. Don't do that. Instead, scaffold the integration automatically.

# In your template steps, add a Vault namespace provisioning step
- id: provision-vault
  name: Provision Vault Namespace
  action: http:backstage:request
  input:
    method: POST
    path: '/api/proxy/vault/v1/sys/namespaces/${{ parameters.name }}'
    headers:
      content-type: 'application/json'
    body:
      policies:
        - '${{ parameters.name }}-read'

And in your skeleton's app code, use a consistent pattern for secret retrieval:

// skeleton/src/config/secrets.ts
import Vault from 'node-vault';

const vault = Vault({
  apiVersion: 'v1',
  endpoint: process.env.VAULT_ADDR,
});

export async function getSecret(secretPath: string): Promise<string> {
  // Uses the service's bound Kubernetes service account token
  await vault.kubernetesLogin({
    role: process.env.SERVICE_NAME,
    jwt: process.env.VAULT_SA_TOKEN,
  });

  const result = await vault.read(`secret/data/${process.env.SERVICE_NAME}/${secretPath}`);
  return result.data.data[secretPath];
}

This pattern enforces Vault-based secrets retrieval with Kubernetes auth. No hardcoded credentials, no .env files committed to repos.

Enforcing Guardrails with Custom Actions

Stock Backstage actions won't cover everything. Sometimes you need to write custom scaffolder actions to enforce your specific policies. Here's a custom action that registers a new service with your security scanning tooling:

// packages/backend/src/plugins/scaffolder/actions/registerSecurityScan.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { z } from 'zod';

export const createRegisterSecurityScanAction = () => {
  return createTemplateAction({
    id: 'security:register-scan',
    description: 'Registers a new service with the security scanning platform',
    schema: {
      input: z.object({
        serviceName: z.string(),
        repoUrl: z.string().url(),
        ownerTeam: z.string(),
      }),
    },
    async handler(ctx) {
      const { serviceName, repoUrl, ownerTeam } = ctx.input;

      ctx.logger.info(`Registering ${serviceName} with security platform`);

      const response = await fetch(`${process.env.SECURITY_PLATFORM_URL}/api/services`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${process.env.SECURITY_PLATFORM_TOKEN}`,
        },
        body: JSON.stringify({
          name: serviceName,
          repository: repoUrl,
          owner: ownerTeam,
          scanFrequency: 'daily',
          alertChannels: [`team-${ownerTeam}-security`],
        }),
      });

      if (!response.ok) {
        throw new Error(`Failed to register with security platform: ${response.statusText}`);
      }

      ctx.logger.info(`Successfully registered ${serviceName}`);
    },
  });
};

Register this action in your backend and now every scaffolded service is automatically onboarded to your security posture management tooling. Zero extra steps for the developer.

Template Governance: Don't Let the Golden Path Get Overgrown

Templates need maintenance. Here's where most Platform Engineering teams drop the ball — they create beautiful templates at v1, and then six months later the security scanning is pinned to an outdated action, the base Docker image has 47 CVEs, and nobody has touched it.

Treat templates like production services:

Version your templates explicitly. Use ${{ parameters.templateVersion }} as a hidden field with a default, and track which services were scaffolded from which version in your catalog metadata.

Create a template update workflow. When a template changes significantly, use the Backstage catalog API to find all services created from that template and notify their owners:

#!/bin/bash
# find-template-descendants.sh
TEMPLATE_NAME="nodejs-service-template"

curl -s "${BACKSTAGE_URL}/api/catalog/entities?filter=spec.template.ref=template:default/${TEMPLATE_NAME}" \
  | jq -r '.[] | .metadata.annotations["github.com/project-slug"]' \
  | while read repo; do
      echo "Service using template: $repo"
      # Notify owners, create update issues, etc.
    done

Automate dependency bumps. Use Renovate or Dependabot on your template skeleton repo to keep base images and action versions current.

Making Developers Want to Use It

Here's the uncomfortable truth: a Golden Path that developers actively route around is worse than no Golden Path. You've created the illusion of governance without the reality.

A few things that dramatically improve template adoption:

Don't make the template do too much. A template that requires 15 minutes of form-filling before a developer sees a repo is a template developers will ask their tech lead to skip. Keep the happy path to under 3 minutes.

Make the opinionated defaults visible, not hidden. In your Backstage template description, explicitly call out what the template sets up for security. "This template configures Snyk scanning, Trivy image scanning, Vault secrets integration, and CODEOWNERS automatically." Developers appreciate knowing what they're getting.

Provide escape hatches with visibility. If a team genuinely needs to deviate from the Golden Path, make that process visible and auditable rather than invisible. An ADR (Architecture Decision Record) step in a modified template is infinitely better than a fork nobody knows about.

The Payoff

When this is working well, onboarding a new microservice goes from "three days of tribal knowledge and Slack messages" to "20 minutes and a working repository with your security posture pre-configured."

More importantly, when your security team asks "are all our services scanning their container images?", the answer becomes "yes, by default" rather than "we think so, let us check." That's the real value of Golden Paths — not just developer productivity, but organizational confidence in your security baseline.

Start with one template, do it properly, and let adoption drive expansion. The Golden Path only stays golden if you keep maintaining it.

Share:

Was this article helpful?

Amara Okafor
Amara Okafor

DevSecOps Lead

Security-first mindset in everything I ship. From zero-trust architectures to supply chain security, I make sure your pipeline doesn't become your weakest link.

Related Articles

More in Platform Engineering

View all →

Discussion