Writing Custom Scaffolder Templates for Node.js Services

Standardizing service creation across distributed engineering teams requires precise automation. When Writing custom scaffolder templates for Node.js services, platform engineers must balance flexibility with strict governance. This guide details the exact configuration, validation steps, and edge-case handling required to deploy production-ready Node.js scaffolds within your internal developer portal.

Context: Standardizing Node.js Service Generation

Platform teams frequently encounter inconsistent project structures, missing CI/CD pipelines, and delayed onboarding when developers manually bootstrap repositories. By integrating a dedicated scaffolding workflow into the broader Plugin Ecosystem & Custom Extensions, organizations enforce architectural guardrails while preserving developer autonomy. The scaffolder translates high-level inputs into fully initialized Node.js projects, complete with dependency management, linting rules, and catalog metadata.

Exact Configuration: Core Template Architecture

A functional Node.js scaffold relies on three critical Backstage actions: fetch:template, publish:github (or equivalent), and catalog:register. The fetch:template action processes EJS or Nunjucks placeholders, injecting parameters like service_name, node_version, and package_manager. Ensure your template.yaml declares apiVersion: scaffolder.backstage.io/v1beta3 and maps input fields to the parameters schema. The template directory must mirror the target repository structure, with package.json and tsconfig.json containing dynamic placeholders (e.g., <%= values.serviceName %>).

Core Scaffolder Definition (template.yaml)

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
 name: nodejs-service-template
 title: Node.js Microservice
 description: Standardized Node.js service with TypeScript and CI/CD
spec:
 owner: platform-team
 type: service
 parameters:
 - title: Service Details
 required: [name, owner]
 properties:
 name:
 title: Service Name
 type: string
 ui:field: EntityNamePicker
 owner:
 title: Owner Group
 type: string
 ui:field: EntityPicker
 steps:
 - id: fetch
 name: Fetch Template
 action: fetch:template
 input:
 url: ./skeleton
 values:
 serviceName: ${{ parameters.name }}
 - id: publish
 name: Publish to GitHub
 action: publish:github
 input:
 allowedHosts: ['github.com']
 description: ${{ parameters.name }} service
 - id: register
 name: Register Catalog
 action: catalog:register
 input:
 repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
 catalogInfoPath: '/catalog-info.yaml'

Dynamic Package Manifest (skeleton/package.json)

{
 "name": "<%= values.serviceName %>",
 "version": "1.0.0",
 "private": true,
 "scripts": {
 "start": "node dist/index.js",
 "build": "tsc",
 "lint": "eslint . --ext .ts"
 },
 "dependencies": {
 "express": "^4.18.2",
 "<% if (values.dbType === 'postgresql') { %>\"pg\": \"^8.11.0\",<% } %>"
 "zod": "^3.22.4"
 },
 "devDependencies": {
 "typescript": "^5.3.0",
 "@types/node": "^20.10.0"
 }
}

Validation: Pre-Flight Checks & Local Testing

Execute the following validation pipeline before merging template changes to production:

  1. Schema Validation: Run yarn backstage-cli templates:validate --path ./path/to/template.yaml to catch YAML syntax errors and missing required fields.
  2. Local Simulation: Start an isolated scaffolder backend instance:
yarn start --filter @backstage/plugin-scaffolder-backend
  1. Workflow Execution: Trigger the template via the local UI. Inspect the temporary repository output.
  2. Dependency Resolution: Navigate to the scaffolded directory and run npm ci or yarn install --frozen-lockfile to verify lockfile compatibility and dependency tree integrity.
  3. Catalog Metadata Verification: Confirm catalog-info.yaml correctly resolves ${{ parameters.owner }} and ${{ parameters.name }} into valid system and owner entity references.

Edge Cases: Conditional Logic & Secret Handling

  • Conditional Dependencies: Use EJS conditionals directly in package.json and docker-compose.yml to inject optional drivers (e.g., <% if (values.dbType === 'postgresql') { %>...<% } %>).
  • Private Registry Authentication: Never hardcode .npmrc tokens. Pass credentials securely via the publish action’s token parameter or inject them through Backstage’s secret management API at runtime.
  • Monorepo Integration: Configure fetch:template with targetPath: ./packages/${values.serviceName}. Ensure the root package.json workspace configuration is updated to include the new service path post-generation.

Common Pitfalls & Rapid Resolution

Symptom Root Cause Resolution
Template silently ignored by Backstage Missing or incorrect apiVersion Update to apiVersion: scaffolder.backstage.io/v1beta3
CI/CD pipeline fails on fetch step Hardcoded absolute paths in fetch:template Switch to relative paths (./skeleton)
Invalid JSON in generated package.json Unescaped EJS delimiters in script blocks Wrap conditional blocks in valid JSON syntax or use template helpers
Service invisible in Developer Portal Skipped catalog:register step Append catalog:register action pointing to /catalog-info.yaml

Frequently Asked Questions

How do I test a custom Node.js scaffolder template locally before publishing? Run yarn backstage-cli templates:validate against your template.yaml, then start a local Backstage instance with the scaffolder plugin enabled. Trigger the template via the UI and inspect the generated repository in a temporary GitHub/GitLab organization.

Can I enforce specific Node.js versions across all scaffolded services? Yes. Define a fixed node_version parameter in the template schema, or hardcode the engines field in the skeleton package.json. You can also use a fetch:template post-action to inject .nvmrc or .node-version files automatically.

What happens if the catalog registration step fails after repository creation? The repository remains intact, but the service won’t appear in the portal. Implement a retry mechanism in the catalog:register step or configure a webhook in the target Git provider to trigger a manual catalog:refresh on push events.