Automating Repository Scaffolding with Backstage Software Templates

This how-to details the repository-creation step of an automated onboarding flow: rendering a skeleton, publishing a protected repository, wiring branch protection and CI, and registering the result in the catalog — all in one scaffolder run. It is the implementation behind Service Onboarding Automation within Developer Experience & Self-Service Platforms, and it builds on the action patterns in Scaffolder Template Design.

Prerequisites

  • @backstage/plugin-scaffolder-backend@^1.22.0 and @backstage/plugin-scaffolder-backend-module-github@^0.5.0 registered in the backend.
  • ${GITHUB_TOKEN} with repo and workflow scopes, org-authorized, injected via app-config.yaml.
  • ${GITHUB_ORG} set to the target organization.
  • @backstage/cli@^0.27.0 for dry-run and validation.
  • A Group entity in the catalog to own the created component.

Exact Configuration

1. Configure the GitHub integration

# app-config.yaml
# Requires @backstage/integration >= 1.9.0
integrations:
  github:
    - host: github.com
      token: ${GITHUB_TOKEN}

2. Render the skeleton

fetch:template processes every file under url through Nunjucks, substituting ${{ values.* }}.

# templates/repo/scaffold.yaml
# Requires scaffolder.backstage.io/v1beta3 (Backstage >= 1.22.0)
    - id: render
      name: Render skeleton
      action: fetch:template
      input:
        url: ./skeleton
        copyWithoutTemplating:
          - .github/workflows/*.yaml
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}

The copyWithoutTemplating list copies CI workflow files verbatim so GitHub Actions’ own ${{ }} expressions are not consumed by the scaffolder — the cleaner alternative to per-expression escaping.

3. Publish the repository with guardrails

    - id: publish
      name: Create repository
      action: publish:github
      input:
        repoUrl: github.com?owner=${GITHUB_ORG}&repo=${{ parameters.name }}
        defaultBranch: main
        protectDefaultBranch: true
        requiredStatusCheckContexts:
          - ci
          - paved-road
        repoVisibility: internal
        deleteBranchOnMerge: true

requiredStatusCheckContexts makes the paved-road check a merge gate from the first commit, tying repository creation to the policy enforcement described in Enforcing Paved-Road Policies with Software Checks.

4. Register the component

    - id: register
      name: Register component
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml

5. Make the run resumable

Repository creation is the least idempotent step. Capture the output and expose a cleanup path so a partial failure is recoverable rather than leaving an orphaned repo.

  output:
    links:
      - title: Repository
        url: ${{ steps.publish.output.remoteUrl }}
    text:
      - title: Cleanup if needed
        content: "Run: gh repo delete ${GITHUB_ORG}/${{ parameters.name }} --yes"

Validation

# Requires @backstage/cli >= 0.27.0
set -euo pipefail

# 1. Validate the template
npx @backstage/cli catalog validate --path ./templates/repo/scaffold.yaml
# expected: "Validated 1 entity ... 0 errors"

# 2. Dry-run renders without leftover placeholders
grep -R '\${{ values' ./dry-run-output && echo "FAIL" || echo "clean render"
# expected: "clean render"

# 3. Workflow files were copied verbatim (Actions expressions intact)
grep -q 'github.sha' ./dry-run-output/.github/workflows/ci.yaml && echo "CI intact"
# expected: "CI intact"

# 4. After a real run, the repo exists and is protected
gh api repos/${GITHUB_ORG}/<name>/branches/main/protection >/dev/null && echo "protected"
# expected: "protected"

Edge Cases & Troubleshooting

Symptom Root Cause Resolution
publish:github returns 422 “name already exists” Re-running after a partial failure Run the documented gh repo delete cleanup, or guard the step with an existence check before retrying
Branch protection not applied Token lacks admin on the new repo, or org requires repo-level approval Use an org-authorized token; verify the org allows automated protection rules
CI workflow expressions rendered empty Workflow files were templated instead of copied Add the workflow glob to copyWithoutTemplating
Component registered but shows “unknown owner” OwnerPicker value not an entity ref Reference ${{ parameters.owner }} directly; it already yields group:default/...
Scaffolder hangs on publish Provider rate limit on a burst of onboarding runs Add retry-with-backoff in the action module and stagger bulk onboarding

Frequently Asked Questions

When should I use copyWithoutTemplating versus escaping expressions?

Prefer copyWithoutTemplating for any file that contains its own templating syntax — GitHub Actions workflows, Helm charts, other tools’ templates. It is far less error-prone than hand-escaping each ${{ }}. Reserve inline escaping for the rare case where a single file mixes scaffolder values and foreign expressions.

How do I make the scaffolder run as the requesting developer rather than a shared token?

Configure the GitHub integration to use the Backstage GitHub auth provider with token forwarding, so publish:github acts with the signed-in user’s credentials. This attributes the new repository and audit events to a real person, consistent with the least-privilege approach in your security governance, and avoids a single powerful shared token.