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. :::