Writing a Custom Scaffolder Action
The scaffolder’s built-in actions cover fetching templates, writing files, and pushing to git — but the moment a golden path needs to register a service in your CMDB, open a Jira epic, or call an internal provisioning API, you need a custom action. This how-to writes, registers, and tests a scaffolder action so your templates can do things the built-ins cannot. It extends Scaffolder Template Design within Plugin Ecosystem & Custom Extensions.
Prerequisites
- Backstage 1.20+ with the scaffolder plugin installed and templates working.
- The new backend system, since custom actions register as a scaffolder module.
- A clear input/output contract for the action — what it takes and what it returns to later steps.
- Credentials for whatever the action calls, injected at runtime, never baked into the action.
Exact Configuration
-
Write the action with typed input and output using
createTemplateAction.// plugins/scaffolder-actions/src/registerCmdb.ts // Requires @backstage/plugin-scaffolder-node >= 0.4 import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { z } from 'zod'; export const registerCmdbAction = createTemplateAction({ id: 'acme:cmdb:register', schema: { input: z.object({ serviceName: z.string(), owner: z.string() }), output: z.object({ cmdbId: z.string() }), }, async handler(ctx) { const { serviceName, owner } = ctx.input; const cmdbId = await cmdb.register(serviceName, owner); ctx.output('cmdbId', cmdbId); ctx.logger.info(`Registered ${serviceName} as ${cmdbId}`); }, }); -
Register the action as a scaffolder module.
// packages/backend/src/index.ts backend.add(import('@internal/plugin-scaffolder-actions')); -
Use it in a template, consuming its output in a later step.
# template.yaml steps: - id: cmdb name: Register in CMDB action: acme:cmdb:register input: { serviceName: ${{ parameters.name }}, owner: ${{ parameters.owner }} } output: text: - title: CMDB ID content: ${{ steps.cmdb.output.cmdbId }}
Validation
# Requires a running backend, curl, jq
# 1. The action is registered and discoverable
curl -s "${PORTAL_URL}/api/scaffolder/v2/actions" | jq '.[] | select(.id=="acme:cmdb:register") | .id'
# Expected: "acme:cmdb:register"
# 2. Input validation rejects a bad payload
# (dry-run a template with a missing required field)
echo "A run missing serviceName should fail schema validation, not the handler."
# 3. A dry-run produces the declared output
curl -s -X POST "${PORTAL_URL}/api/scaffolder/v2/dry-run" -d @dryrun.json | jq '.steps[].output'
# Expected: a cmdbId in the action's step output
Edge Cases & Troubleshooting
| Symptom | Root Cause | Resolution |
|---|---|---|
| Action not found in a template | Module not added to the backend | Add the scaffolder-actions module |
| Later step cannot read output | Output not emitted or misnamed | Call ctx.output with the schema’s key |
| Bad input reaches the handler | No input schema | Declare a Zod input schema; invalid runs fail early |
| Secrets leak into logs | Logging the whole input | Log identifiers, never credentials |
| Action partially applies on failure | No idempotency or rollback | Make the action idempotent; check-before-create |
Frequently Asked Questions
How do I keep an action from making a mess when a run is retried?
Make it idempotent: check whether the thing already exists before creating it, and key on a stable identifier so a retry updates rather than duplicates. Scaffolder runs fail and get retried, and an action that assumes it runs exactly once will leave orphaned records the first time that assumption breaks.
Should one action do several things?
Prefer small, single-purpose actions composed in the template over one action that registers, notifies, and provisions. Small actions are testable, reusable across templates, and fail in ways you can reason about. A monolithic action is hard to dry-run and turns every template that uses it into an all-or-nothing step.