Most "golden paths" are a wiki page: a paragraph of prose telling a developer to create a repo, copy a manifest, wire up a domain, and remember to connect it to CI. Backstage's actual pitch is narrower and more useful than that — a golden path should be a form a developer fills out once, that produces a running service with nothing left to remember. This post builds one, concretely, for a Cluster-API-based self-hosted PaaS: a Backstage Software Template that takes a developer from clicking "New Component" to a live https://<name>.onbex.co URL, with the actual template.yaml, the custom scaffolder action that talks to the platform's deploy API, and the generated manifest it writes.
What the template actually has to do
A Backstage Software Template is a YAML file plus a set of steps, each one invoking a named action — some built into Backstage (fetch:template, publish:github, catalog:register), some custom. For a deploy-from-git PaaS, five things have to happen in order, and each one maps to a step:
| Step | What it does | Action |
|---|---|---|
| Collect inputs | Service name, port, health check path | parameters (JSONSchema) |
| Generate the skeleton | Render a starter repo + manifest from templated files | fetch:template |
| Create the repo | Push the rendered skeleton to a new GitHub repo | publish:github |
| Provision + connect | Create the app on the platform, wire the repo, wait for the first deploy | a custom action |
| Register + surface the URL | Add the service to the catalog, link the live URL | catalog:register + output |
Everything except "provision + connect" is stock Backstage. That one step is the entire job of registering a self-hosted PaaS as a golden-path target — get that step right and the rest of the template is boilerplate any platform team already knows how to write.
The template.yaml
This is the whole form a developer sees, start to finish:
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: bex-web-service
title: New bex Web Service
description: >
Scaffolds a new Node/Python/Go web service, pushes it to a repo,
and deploys it live on the bex fleet at <name>.onbex.co.
spec:
owner: platform-team
type: service
parameters:
- title: Service Details
required: [name, port]
properties:
name:
type: string
title: Service name
description: Also becomes the subdomain — name.onbex.co
pattern: "^[a-z][a-z0-9-]{2,30}$"
port:
type: integer
title: Port the app listens on
default: 3000
healthCheckPath:
type: string
title: Health check path
default: /
repoUrl:
type: string
title: Repository Location
ui:field: RepoUrlPicker
ui:options:
allowedHosts: [github.com]
steps:
- id: fetch-skeleton
name: Generate service skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
port: ${{ parameters.port }}
healthCheckPath: ${{ parameters.healthCheckPath }}
- id: publish-repo
name: Publish to GitHub
action: publish:github
input:
repoUrl: ${{ parameters.repoUrl }}
description: bex-deployed service, scaffolded from the platform golden path
- id: bex-deploy
name: Provision on bex and deploy
action: bex:app:deploy
input:
name: ${{ parameters.name }}
port: ${{ parameters.port }}
healthCheckPath: ${{ parameters.healthCheckPath }}
repoUrl: ${{ steps.publish-repo.output.remoteUrl }}
- id: register
name: Register in the catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish-repo.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: View live service
url: ${{ steps.bex-deploy.output.liveUrl }}
- title: Repository
url: ${{ steps.publish-repo.output.remoteUrl }}
- title: Open in catalog
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}Two things about the skeleton/ directory fetch:template pulls from are worth calling out. First, it templates a catalog-info.yaml (so catalog:register has something to find) and a starter app in whatever language the platform team standardizes on. Second — and this is the detail that trips people up — it does not template an image: field into the generated bex.yml. This repo's own manifest only has seven fields (name, type, image, port, replicas, healthCheckPath, domains), and image gets written by bex's own build pipeline the moment the first push lands and produces a tagged image — not by the scaffolder, which has no image to reference yet. The generated manifest the template writes looks like this:
apps:
- name: my-service
type: web
port: 3000
replicas: 1
healthCheckPath: /
domains:
- my-service.onbex.coThe one custom action: bex:app:deploy
Everything Backstage-specific about targeting bex lives in a single custom scaffolder action. It does three things a generic template can't: call bex's Render-compatible deploy API to create the app, connect the freshly published repo so bex's own git-push listener picks up the first commit, and — this is the part a wiki-page golden path always skips — poll until the deploy is actually live instead of returning the moment the API call was accepted.
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
export const createBexDeployAction = () =>
createTemplateAction({
id: 'bex:app:deploy',
description: 'Creates a bex app, connects its repo, and waits for the first deploy to go live.',
schema: {
input: {
name: z => z.string({ description: 'Service name and subdomain' }),
port: z => z.number({ description: 'Port the app listens on' }),
healthCheckPath: z => z.string({ description: 'Health check path' }),
repoUrl: z => z.string({ description: 'Git remote URL published in the previous step' }),
},
output: {
liveUrl: z => z.string(),
appId: z => z.string(),
},
},
async handler(ctx) {
const { name, port, healthCheckPath, repoUrl } = ctx.input;
const token = ctx.secrets?.BEX_API_TOKEN;
const createRes = await fetch('https://api.bex.co/v1/apps', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type: 'web', port, healthCheckPath, repoUrl }),
});
const { id: appId } = await createRes.json();
ctx.logger.info(`Created bex app ${appId}, waiting for first deploy...`);
// State-confirming read: poll a structured status field, not a spinner.
const deadline = Date.now() + 5 * 60 * 1000;
let status = 'building';
while (status !== 'live' && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 5000));
const statusRes = await fetch(`https://api.bex.co/v1/apps/${appId}/deploys/latest`, {
headers: { Authorization: `Bearer ${token}` },
});
({ status } = await statusRes.json());
if (status === 'failed') throw new Error(`Deploy failed for ${name} — check bex dashboard`);
}
if (status !== 'live') throw new Error(`Deploy for ${name} did not go live within 5 minutes`);
ctx.output('appId', appId);
ctx.output('liveUrl', `https://${name}.onbex.co`);
},
});Registering it takes one backend module, added once per Backstage instance:
import { createBackendModule } from '@backstage/backend-plugin-api';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node';
import { createBexDeployAction } from './actions/bexDeploy';
export const bexScaffolderModule = createBackendModule({
pluginId: 'scaffolder',
moduleId: 'bex-actions',
register(env) {
env.registerInit({
deps: { scaffolder: scaffolderActionsExtensionPoint },
async init({ scaffolder }) {
scaffolder.addActions(createBexDeployAction());
},
});
},
});The BEX_API_TOKEN referenced in ctx.secrets is a scoped, org-level machine token stored in the backend's own secret store — not a per-developer credential, since the action runs server-side inside Backstage, on the platform team's infrastructure, not the requesting developer's browser.
One failure mode worth designing for explicitly: a developer's browser tab dies mid-run, or the scaffolder task times out on the frontend, and they hit "Create" a second time for the same service name. The POST /v1/apps call above is naturally idempotent on name — bex keys an app on its subdomain, so a second create for my-service returns the existing app's ID instead of standing up a duplicate — but that's a property of bex's API, not something the template adds. If you're wiring a similar action against a platform that doesn't key creates that way, add a client-supplied idempotency key to the request and check for a 409-with-existing-ID response before assuming "create" always means "create." A golden path that silently double-provisions on a retried click is worse than a wiki page, because nobody's reading the runbook to notice.
Testing the template before a developer does
Backstage ships a local dry-run for exactly this: backstage-cli plugin:build on the actions module, then pointing a local Backstage instance's catalog at the template repo via a file: Location and running it through the "Create Component" form against a scratch bex org/token before merging. The one thing worth checking by hand every time the skeleton or action changes: does a failed bex-deploy step actually surface the platform's error text in the Backstage task log, or does it just say "Step failed"? The custom action above throws the real message from bex's API (Deploy failed for ${name} — check bex dashboard) specifically so a developer debugging a bad health check path doesn't have to leave Backstage to find out why.
What "registering bex as a Backstage target" actually costs
This is the part every "just use Backstage" recommendation skips, and the honest answer is smaller than it sounds: nothing bex-specific has to exist inside Backstage itself. There's no bex plugin to install from a marketplace, no vendor integration to wait on. Backstage's scaffolder is action-agnostic by design — it doesn't know or care that bex:app:deploy calls a self-hosted platform instead of a SaaS one. The actual checklist is three items, all owned by the platform team, not by bex:
- A template repo Backstage's catalog points at — either committed straight into the org's existing template collection or its own repo, referenced by a
Locationentity so it shows up in "Create Component." - One backend module — the ~20 lines above, installed into
packages/backendlike any other scaffolder action plugin. - A scoped API token — the only thing bex has to provide is a stable, machine-callable REST endpoint that returns structured status, which it already does (this is the same API surface audited for agent-walkability in an earlier post — a golden path that works for a human clicking through Backstage and an agent calling the same endpoints unsupervised turn out to need the same contract: an idempotent create call and a state-confirming read, not a spinner).
That last connection is the actual payoff of building this as a proper template instead of a wiki page: the same bex:app:deploy action that makes the Backstage form one click also makes bex a target an AI agent can drive through the identical API, because neither path routes through a rendered dashboard a human has to interpret. A golden path built on a real API instead of a UI serves both callers for free.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with a REST/GraphQL API that a Backstage template (or an agent) can drive end-to-end without ever touching a dashboard. Star the repo on GitHub or deploy your first app today.



