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.

Automated scaffolding needs four things: the scaffolder with its GitHub module, an org-authorized token with repo and workflow scopes, the target org set, and a Group to own the created component.

Prerequisites for repository scaffolding The scaffolder GitHub module, a scoped token, the target org, and an owning group enable automated scaffolding. scaffolder + github repo+workflow token target org set owning group Scaffolding runs one run
The token must be org-authorized, or branch protection silently fails to apply on the newly created repo.
  • @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

The run is a four-action chain: render the skeleton (copying CI files verbatim), publish a protected repo with required status checks, register the component, and expose a cleanup path so a partial failure is recoverable. The diagram sequences it.

Repository scaffolding action chain Render the skeleton, publish a protected repo, register the component, and expose a cleanup path. Render skeleton copyWithoutTemplating Publish protected required checks Register catalog entity Cleanup path recoverable
copyWithoutTemplating ships CI workflows verbatim, so GitHub Actions' own workflow-expression syntax is not consumed by the scaffolder.

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"

Validate four things: the template schema-validates, the dry-run leaves no ${{ values placeholder, CI workflow files were copied verbatim with their expressions intact, and after a real run the repo exists and is branch-protected.

Four scaffolding validation checks Schema validation, a placeholder scan, a verbatim-CI check, and a branch-protection check validate the scaffolding run. Schema validate 0 errors No placeholders clean render CI intact verbatim copy Repo protected after real run
An empty CI expression in the created repo means the workflow was templated instead of copied — add it to the verbatim glob.
# 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"

The scaffolding pitfalls split into idempotency issues (name collision on retry, rate-limit hangs) and configuration issues (missing protection, empty CI expressions, unknown owner). The diagram groups them so the fix is either a retry guard or a config correction.

Repository-scaffolding pitfalls by class Name collisions and rate-limit hangs are idempotency issues; missing protection, empty CI, and unknown owner are configuration issues. Idempotency 422 name exists rate-limit hang → cleanup + backoff Configuration no protection empty CI / owner → org token + verbatim
The documented gh repo delete cleanup is what makes a 422-on-retry a recoverable state, not a manual dead-end.
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.