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.0and@backstage/plugin-scaffolder-backend-module-github@^0.5.0registered in the backend.${GITHUB_TOKEN}withrepoandworkflowscopes, org-authorized, injected viaapp-config.yaml.${GITHUB_ORG}set to the target organization.@backstage/cli@^0.27.0for dry-run and validation.- A
Groupentity 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.
Related
- Service Onboarding Automation — parent sub-section
- Developer Experience & Self-Service Platforms — section overview
- Creating a Golden Path Template for Microservices — the golden path this scaffolding serves
- Onboarding New Engineers with Self-Service Workflows — applying the same engine to people