Implementing Internal Developer Platform Golden Paths With Backstage Software Templates
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.
Was this article helpful?
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
Backstage TechDocs Not Rendering: Fixing MkDocs Build Failures And Missing Plugin Errors
TechDocs is one of the most powerful features in Backstage — the idea of docs-as-code, living right next to your services in a unified developer portal, is...
ArgoCD Application Stuck In Progressing State: Debugging Sync Failures And Manual Recovery Steps
If you've worked with ArgoCD for any meaningful amount of time, you've probably stared at that dreaded "Progressing" status, watching your application sync...
ArgoCD Application Stuck In Progressing State: Diagnosing And Fixing Sync Failures
If you've worked with ArgoCD for more than five minutes, you've probably encountered this frustrating scenario: your application gets stuck in a "Progressi...
Backstage Developer Portal Setup for Platform Teams
How to set up Spotify's Backstage as a developer portal — software catalog, templates, and TechDocs for platform teams that want to scale.
Crossplane: Managing Cloud Infrastructure from Kubernetes
How to use Crossplane to provision and manage cloud infrastructure using Kubernetes-native APIs — one control plane to rule them all.
Pulumi vs Terraform: An Honest Comparison from the Trenches
A real-world comparison of Pulumi and Terraform — where each shines, where each hurts, and how to pick the right one for your team.
More in Platform Engineering
View all →DevOps Team Onboarding Checklist: Get New Engineers Productive in Week One
A practical week-one DevOps onboarding checklist covering access, tooling, pipelines, and culture to get new engineers contributing fast.