Developer Portals with Backstage
Software Templates (Scaffolder)
4 min read
Software Templates are Backstage's killer feature for platform teams. They enable developers to create new projects that automatically follow your organization's best practices—this is where golden paths become reality.
Why Software Templates?
Without templates, every new project starts differently:
Without Templates:
Developer A: "I'll set up CI/CD manually..."
Developer B: "Let me copy from an old project..."
Developer C: "What's the standard way again?"
Result: Inconsistent projects, security gaps, missing docs
With Templates:
All Developers: "Click → Fill form → Get standardized project"
Result: Consistent structure, security built-in, docs included
Template Anatomy
A Backstage template has three main sections:
# template.yaml structure
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: template-name
title: Human-Readable Title
description: What this template creates
tags: [tag1, tag2]
spec:
owner: platform-team
type: service # service, website, library, etc.
# SECTION 1: Parameters (user input)
parameters:
- title: Step 1 Title
properties:
fieldName:
type: string
# SECTION 2: Steps (actions to perform)
steps:
- id: step-id
name: Step Name
action: action:name
input:
key: value
# SECTION 3: Output (what to show user)
output:
links:
- title: Link Title
url: ${{ steps.step-id.output.someUrl }}
Complete Template Example
Here's a production-ready Node.js API template:
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: nodejs-api-template
title: Node.js REST API
description: |
Creates a production-ready Node.js REST API with Express,
TypeScript, Docker, Kubernetes manifests, and CI/CD.
tags:
- nodejs
- typescript
- api
- recommended
spec:
owner: group:platform-team
type: service
parameters:
# Step 1: Basic Information
- title: Service Information
required:
- name
- description
- owner
properties:
name:
title: Service Name
type: string
description: Unique name for your service
pattern: '^[a-z0-9-]+$'
ui:autofocus: true
ui:help: 'Lowercase letters, numbers, and hyphens only'
description:
title: Description
type: string
description: What does this service do?
owner:
title: Owner
type: string
description: Team that owns this service
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
# Step 2: Technical Options
- title: Technical Configuration
properties:
database:
title: Include Database?
type: boolean
default: false
databaseType:
title: Database Type
type: string
enum:
- postgresql
- mysql
- mongodb
enumNames:
- PostgreSQL (Recommended)
- MySQL
- MongoDB
default: postgresql
ui:widget: select
ui:options:
hidden: '{{ not parameters.database }}'
authentication:
title: Authentication Method
type: string
enum:
- jwt
- oauth2
- none
default: jwt
# Step 3: Repository Settings
- title: Repository Configuration
required:
- repoUrl
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
allowedOwners:
- acme-corp
steps:
# Step 1: Fetch and template the skeleton
- id: fetch-base
name: Fetch Base Template
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
database: ${{ parameters.database }}
databaseType: ${{ parameters.databaseType }}
authentication: ${{ parameters.authentication }}
# Step 2: Append database config if needed
- id: fetch-database
name: Add Database Configuration
if: ${{ parameters.database }}
action: fetch:template
input:
url: ./skeleton-database-${{ parameters.databaseType }}
targetPath: ./src/database
# Step 3: Create GitHub repository
- id: publish
name: Publish to GitHub
action: publish:github
input:
repoUrl: ${{ parameters.repoUrl }}
description: ${{ parameters.description }}
defaultBranch: main
protectDefaultBranch: true
requireCodeOwnerReviews: true
repoVisibility: internal
# Step 4: Create GitHub environments
- id: github-environments
name: Create Environments
action: github:environment:create
input:
repoUrl: ${{ parameters.repoUrl }}
name: production
deploymentBranchPolicy:
protectedBranches: true
# Step 5: Register in Backstage catalog
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
# Step 6: Trigger initial CI run
- id: trigger-ci
name: Trigger CI Pipeline
action: github:actions:dispatch
input:
repoUrl: ${{ parameters.repoUrl }}
workflowId: ci.yaml
branchOrTagName: main
output:
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Open in Catalog
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}
- title: CI Pipeline
url: ${{ steps.publish.output.remoteUrl }}/actions
Template Skeleton Structure
The ./skeleton directory contains the template files:
skeleton/
├── catalog-info.yaml
├── package.json
├── tsconfig.json
├── Dockerfile
├── .github/
│ └── workflows/
│ ├── ci.yaml
│ └── deploy.yaml
├── kubernetes/
│ ├── deployment.yaml
│ └── service.yaml
├── src/
│ ├── index.ts
│ ├── routes/
│ └── middleware/
└── docs/
└── index.md
Example skeleton file with templating:
# skeleton/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
description: ${{ values.description }}
annotations:
github.com/project-slug: acme-corp/${{ values.name }}
backstage.io/techdocs-ref: dir:.
spec:
type: service
lifecycle: experimental
owner: ${{ values.owner }}
Built-in Actions
Backstage provides many built-in actions:
# Common scaffolder actions
actions:
fetch:
- fetch:template # Fetch and render template
- fetch:plain # Fetch without rendering
publish:
- publish:github # Create GitHub repo
- publish:gitlab # Create GitLab repo
- publish:bitbucket # Create Bitbucket repo
catalog:
- catalog:register # Register entity in catalog
- catalog:write # Write catalog-info.yaml
github:
- github:actions:dispatch # Trigger workflow
- github:environment:create # Create environment
- github:issues:create # Create issue
debug:
- debug:log # Log values for debugging
Custom Actions
Create custom actions for your organization:
// plugins/scaffolder-backend-module-acme/src/actions/createDatabase.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
export const createDatabaseAction = createTemplateAction({
id: 'acme:database:create',
description: 'Creates a database via Crossplane',
schema: {
input: {
type: 'object',
required: ['name', 'engine'],
properties: {
name: { type: 'string' },
engine: { type: 'string', enum: ['postgresql', 'mysql'] },
size: { type: 'string', default: 'small' },
},
},
},
async handler(ctx) {
const { name, engine, size } = ctx.input;
// Create Crossplane claim
await ctx.createKubernetesResource({
apiVersion: 'database.acme.io/v1',
kind: 'DatabaseClaim',
metadata: { name },
spec: { engine, size },
});
ctx.output('databaseName', name);
ctx.output('connectionSecretName', `${name}-connection`);
},
});
In the next lesson, we'll explore TechDocs and the plugin ecosystem. :::