Templates and Scaffolding
New service creation is a surprisingly good proxy for platform maturity. At an immature org it takes a sprint: find the right IAM permissions, clone someone's existing repo and gut it, figure out which CI template to use, manually register the service somewhere. At a mature one it takes twenty minutes and a form.
Scaffolding is how you close that gap. Templates encode the golden path decisions from the previous post into runnable starting points. A new engineer should be able to create a production-ready service skeleton without talking to anyone on the platform team.
The Scaffolding Stack
Three tools dominate this space. They're not mutually exclusive.
Backstage Software Templates. The full IDP approach. You get a UI, a catalog, plugin ecosystem, and a workflow engine. High ceiling, high setup cost. Best when you need this to be a visible, self-service product that non-technical stakeholders can see.
cookiecutter / copier. CLI-first. Lower setup cost, easier to version-control, works without a running service. Best for smaller teams or as the underlying engine that Backstage calls.
Bespoke scaffolders. A script, a Go binary, a GitHub Action — whatever fits your stack. Sometimes the right answer is a 200-line Python script that does exactly what you need and nothing else.
What to Bake In
The scaffolding decision tree: if every new service of type X needs it, bake it in. If only some services need it, make it an optional flag. If one service needs it, let that team handle it.
Always bake in:
- Directory structure and language-appropriate linter config
- Dockerfile following your golden path base image
- Reusable CI workflow reference (not a copy — a
uses:reference) catalog-info.yamlstub pre-filled with service name and owner.trivyignoreor equivalent pointing to your CVE policy- Standard health check endpoint
OWNERSorCODEOWNERSfile
Offer as options:
- Database migration setup (flag:
--database postgres) - gRPC vs HTTP API scaffolding
- Queue consumer boilerplate
- Async worker vs synchronous API
Leave out entirely:
- Business logic (obvious, but scaffolders get feature-crept)
- Tool installs that belong in dev container config
- Anything that will be wrong in six months
A Minimal copier Template
# Directory layout
templates/
python-api/
copier.yaml
{{project_name}}/
__init__.py
main.py
health.py
Dockerfile
.github/
workflows/
ci.yml
catalog-info.yaml# copier.yaml
questions:
project_name:
type: str
help: "Service name (lowercase, hyphens ok)"
team_name:
type: str
help: "Owning team slug"
needs_database:
type: bool
default: false
help: "Does this service need a Postgres database?"
tasks:
- "pip install -r requirements.txt"
- "git init"
- "git add ."
- "git commit -m 'chore: scaffold from platform template'"# {{project_name}}/health.py — always included
from fastapi import APIRouter
router = APIRouter()
@router.get("/health/live")
def liveness():
return {"status": "ok"}
@router.get("/health/ready")
def readiness():
# Override this in your service to check real dependencies
return {"status": "ok"}Backstage Software Template Example
For teams that have Backstage, a Software Template is a YAML file in your catalog:
# templates/python-api/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: python-api
title: Python API Service
description: FastAPI service with observability, CI, and catalog registration baked in
tags:
- python
- api
- recommended
spec:
owner: platform-team
type: service
parameters:
- title: Service details
required: [name, owner]
properties:
name:
title: Service name
type: string
pattern: '^[a-z][a-z0-9-]*$'
owner:
title: Owning team
type: string
ui:field: OwnerPicker
needsDatabase:
title: Needs Postgres database?
type: boolean
default: false
steps:
- id: fetch-template
name: Fetch template files
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
owner: ${{ parameters.owner }}
needsDatabase: ${{ parameters.needsDatabase }}
- id: create-repo
name: Create GitHub repo
action: publish:github
input:
repoUrl: github.com?owner=my-org&repo=${{ parameters.name }}
defaultBranch: main
- id: register-catalog
name: Register in catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps['create-repo'].output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yamlKeeping Templates Fresh
Templates rot. The language version you baked in six months ago has a CVE. The CI workflow reference points to a deprecated action. The base Docker image is three major versions behind.
Treat templates as living code:
- Pin to specific versions, not
latest - Run a weekly job that opens a PR against the template itself when dependencies need updating
- When you update a template, provide a migration guide (or a migration script) for services already scaffolded from it
# .github/workflows/template-audit.yml
name: Template dependency audit
on:
schedule:
- cron: '0 9 * * 1' # Mondays
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check base image freshness
run: |
python scripts/check_template_deps.py \
--template templates/python-api \
--fail-on-major-driftKey Takeaways
- Scaffolding turns golden path decisions into executable starting points — the goal is production-readiness in minutes, not days.
- Backstage Software Templates suit larger orgs that need a visible, UI-driven self-service workflow; copier/cookiecutter suits smaller teams or serves as the engine underneath Backstage.
- Bake in what every service of a type needs: CI reference, health endpoint, catalog stub, linter config. Use flags for common optional features. Leave business logic out entirely.
- Templates are living code — pin dependency versions and run automated audits to catch drift before engineers scaffold from a stale template.
- A migration path for already-scaffolded services is as important as the template itself; without it, every template update creates N divergent forks.
- The best scaffolding disappears: engineers stop thinking about it because it just works.