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.
Prerequisites for a custom action A working scaffolder, the new backend system, a clear contract, and injected credentials enable a custom action. scaffolder works new backend system input/output contract injected credentials Custom action in templates
Define the input and output schema first — a typed contract is what lets later template steps consume the action's result safely.

Exact Configuration

  1. 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}`);
      },
    });
    
  2. Register the action as a scaffolder module.

    // packages/backend/src/index.ts
    backend.add(import('@internal/plugin-scaffolder-actions'));
    
  3. 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 }}
    
Action in a template run A template step invokes the custom action with typed input; the action returns output a later step consumes. template step typed input custom action handler runs ctx.output cmdbId later step consumes
Emit outputs through ctx.output so downstream steps and the template's result page can reference what the action produced.

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
Custom action validation checks The action is discoverable, input validation rejects bad payloads, and a dry-run yields the declared output. Discoverable in actions list Input validated bad payload fails Output emitted on dry-run
Dry-run is the safe test loop — it exercises the schema and handler wiring without actually registering anything in the CMDB.

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
Custom action pitfalls by class Not-found and unread-output are registration issues; missing schema and non-idempotency are contract and safety issues. Registration not found output unread → add module + ctx.output Contract & safety no schema not idempotent → schema + idempotent
Idempotency matters because templates get re-run — an action that creates blindly leaves duplicates when a run is retried.

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.