Backstage: Golden Paths, RBAC and Controlling Who Deploys Where

The problem: freedom without control = chaos
Picture this: you have 5 dev teams, each running 3 or 4 microservices. Everyone has access to Backstage and can create templates, generate manifests and deploy to Kubernetes. Sounds great, right?
Until the payments team accidentally deploys to the billing production namespace. Or someone from the frontend team runs a kubectl apply directly to the staging cluster, bypassing the GitOps flow. Or worse: a junior who just joined deletes a ConfigMap from another team because “I thought it was a test resource”.
Sound familiar? Then you need two things:
- Golden Paths — pre-defined paths that guide developers through the right workflow.
- RBAC — access control so everyone can only do what they’re supposed to.
What are Golden Paths?
A Golden Path is a pre-defined, opinionated workflow that guides developers toward your organization’s best practices. Think of it as the “happy path” but institutionalized.
Instead of telling a developer “create a repo, set up CI, write the Kubernetes manifests, register the service in the catalog and configure ArgoCD”, you say: “fill out this form and we’ll take care of the rest”.
Why are they so important?
- Fast onboarding: A new developer can deploy their first service on day one.
- Consistency: All services follow the same structure, naming, labels and annotations.
- Security by design: The template already includes network policies, resource limits and Kubernetes RBAC.
- Fewer mistakes: No room for “I forgot to add the health check” or “I didn’t set resource limits”.
Prerequisites
Before continuing, you need:
| Tool | Version | What for |
|---|---|---|
| Backstage | v1.49+ | Developer portal |
| Node.js | 22+ | Backstage runtime |
| Kubernetes | 1.28+ | Target cluster |
| kubectl | 1.28+ | Cluster access |
If you’ve been following the Backstage series posts, you already have most of the setup ready.
Step 1: Create a Golden Path Template
We’ll create a template that lets a developer deploy a new service in a standardized way. The key is that the template controls which namespace you can deploy to, based on the user’s team.
Create the file templates/golden-path-service/template.yaml:
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: golden-path-service
title: "🛤️ Golden Path: New Service"
description: Creates a standardized service with repo, CI, K8s manifests and catalog registration
tags:
- golden-path
- kubernetes
- recommended
spec:
owner: group:default/platform-team
type: service
parameters:
- title: Service Information
required:
- name
- owner
properties:
name:
title: Service name
type: string
description: Name in kebab-case (e.g. api-payments)
pattern: "^[a-z][a-z0-9-]*$"
ui:autofocus: true
description:
title: Description
type: string
owner:
title: Owner team
type: string
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
- title: Deployment Configuration
required:
- environment
- namespace
properties:
environment:
title: Environment
type: string
enum:
- dev
- staging
enumNames:
- "Development"
- "Staging"
default: dev
namespace:
title: Namespace
type: string
description: Namespace is assigned based on your team
replicas:
title: Replicas
type: integer
default: 2
minimum: 1
maximum: 5
port:
title: Service port
type: integer
default: 8080
- title: Repository
required:
- repoUrl
properties:
repoUrl:
title: Repository location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
steps:
- id: fetch-template
name: Generate project structure
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
namespace: ${{ parameters.namespace }}
environment: ${{ parameters.environment }}
replicas: ${{ parameters.replicas }}
port: ${{ parameters.port }}
- id: publish-repo
name: Create GitHub repository
action: publish:github
input:
allowedHosts: ["github.com"]
repoUrl: ${{ parameters.repoUrl }}
description: ${{ parameters.description }}
defaultBranch: main
repoVisibility: internal
- id: register-catalog
name: Register in catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps['publish-repo'].output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps['publish-repo'].output.remoteUrl }}
- title: View in catalog
icon: catalog
entityRef: ${{ steps['register-catalog'].output.entityRef }}Notice some important details:
- The
environmentfield only offersdevandstaging. Nobody can deploy to production from this template. - The
namespaceis controlled at the template level, not freely typed by the user. - The
ownerusesOwnerPickerwhich only shows groups from the catalog.
Step 2: The project skeleton
The template generates a standard structure. Create the folder templates/golden-path-service/skeleton/ with these files:
catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
description: ${{ values.description }}
annotations:
github.com/project-slug: ${{ values.repoUrl | projectSlug }}
backstage.io/kubernetes-namespace: ${{ values.namespace }}
argocd/app-name: ${{ values.name }}
tags:
- golden-path
spec:
type: service
lifecycle: experimental
owner: ${{ values.owner }}k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${{ values.name }}
namespace: ${{ values.namespace }}
labels:
app: ${{ values.name }}
team: ${{ values.owner | replace("group:default/", "") }}
managed-by: backstage
spec:
replicas: ${{ values.replicas }}
selector:
matchLabels:
app: ${{ values.name }}
template:
metadata:
labels:
app: ${{ values.name }}
team: ${{ values.owner | replace("group:default/", "") }}
spec:
containers:
- name: ${{ values.name }}
image: ghcr.io/your-org/${{ values.name }}:latest
ports:
- containerPort: ${{ values.port }}
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
httpGet:
path: /health
port: ${{ values.port }}
initialDelaySeconds: 10
readinessProbe:
httpGet:
path: /ready
port: ${{ values.port }}
initialDelaySeconds: 5Notice the skeleton already includes:
- Resource limits (not optional, always present).
- Health checks (liveness and readiness).
- Team labels (for filtering in dashboards and network policies).
- Fixed namespace based on the template parameter.
This is the power of a Golden Path: best practices are baked in, they don’t depend on the developer remembering them.
Step 3: Understanding the Permission Framework
Before configuring RBAC, you need to understand how Backstage’s permission system works. It has three key concepts:
- Permission: Represents a concrete action, like
catalog.entity.readorscaffolder.task.create. - Policy: The function that evaluates whether a user can perform that action. It can return
ALLOW,DENYorCONDITIONAL. - Resource: The resource the action applies to (a template, a catalog entity, etc.).
The trick is in conditional decisions: instead of a simple yes/no, you can say “yes, but only if the user owns the entity” or “yes, but only for entities with a certain annotation”.
Step 4: Enable the Permission Framework
First, enable the permission system in app-config.yaml:
permission:
enabled: trueThen install the permission module in the backend:
yarn workspace backend add @backstage/plugin-permission-backend-module-allow-all-policyallow-all-policy module is just for getting started. In production you should never use a policy that allows everything. We’ll replace it in the next step.Register it in packages/backend/src/index.ts:
backend.add(
import('@backstage/plugin-permission-backend-module-allow-all-policy'),
);Step 5: Configure RBAC
Now let’s set up real permissions. Install the RBAC plugin:
yarn workspace backend add @backstage-community/plugin-rbac-backendReplace the allow-all-policy module in packages/backend/src/index.ts:
// Remove this:
// backend.add(import('@backstage/plugin-permission-backend-module-allow-all-policy'));
// Add this:
backend.add(import('@backstage-community/plugin-rbac-backend'));Define roles and policies
Configure the roles in app-config.yaml:
permission:
enabled: true
rbac:
admin:
superUsers:
- name: group:default/platform-team
pluginsWithPermission:
- catalog
- scaffolder
policies-csv-file: ./rbac-policies.csvCreate the rbac-policies.csv file with the policies:
# Format: policy_type, subject, permission, effect
# p = policy (permission rule)
# g = group (role assignment)
# Roles
g, group:default/team-payments, role:default/payments-deployer
g, group:default/team-frontend, role:default/frontend-deployer
g, group:default/platform-team, role:default/platform-admin
# Platform admins can do everything
p, role:default/platform-admin, catalog.entity.read, allow
p, role:default/platform-admin, catalog.entity.create, allow
p, role:default/platform-admin, catalog.entity.delete, allow
p, role:default/platform-admin, scaffolder.template.read, allow
p, role:default/platform-admin, scaffolder.task.create, allow
p, role:default/platform-admin, scaffolder.task.cancel, allow
p, role:default/platform-admin, scaffolder.task.read, allow
# Deployers can view catalog and execute templates
p, role:default/payments-deployer, catalog.entity.read, allow
p, role:default/payments-deployer, scaffolder.template.read, allow
p, role:default/payments-deployer, scaffolder.task.create, allow
p, role:default/payments-deployer, scaffolder.task.read, allow
p, role:default/frontend-deployer, catalog.entity.read, allow
p, role:default/frontend-deployer, scaffolder.template.read, allow
p, role:default/frontend-deployer, scaffolder.task.create, allow
p, role:default/frontend-deployer, scaffolder.task.read, allowThis establishes that:
- platform-team can do everything (they’re the platform admins).
- team-payments can view the catalog and execute templates, but not delete entities.
- team-frontend has the same permissions as payments (view and execute).
Step 6: Restrict namespaces per team
This is where it gets interesting. We want each team to only deploy to their namespace. To achieve this, we combine two strategies:
Strategy 1: Predefined namespaces in the template
The simplest approach is to have the template define valid namespaces per team:
# In the template, replace the namespace field with this:
namespace:
title: Namespace
type: string
description: Namespace assigned to your team
oneOf:
- const: payments-dev
title: "payments-dev (Team Payments)"
- const: payments-staging
title: "payments-staging (Team Payments)"
- const: frontend-dev
title: "frontend-dev (Team Frontend)"
- const: frontend-staging
title: "frontend-staging (Team Frontend)"This works, but has a problem: any user can choose any namespace from the dropdown. To truly restrict it, you need strategy 2.
Strategy 2: Separate templates per team
Create a team-specific template with hardcoded namespaces:
# template-payments.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: golden-path-payments
title: "🛤️ Golden Path: Payments Service"
tags:
- golden-path
- payments
spec:
owner: group:default/team-payments
type: service
parameters:
- title: Service Information
properties:
name:
title: Service name
type: string
pattern: "^[a-z][a-z0-9-]*$"
environment:
title: Environment
type: string
enum: [dev, staging]
default: dev
steps:
- id: fetch-template
name: Generate project
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
# Namespace is automatically calculated
namespace: payments-${{ parameters.environment }}
owner: group:default/team-paymentsNotice: the namespace is not a free parameter. It’s calculated as payments-{environment}. The payments team can only deploy to payments-dev or payments-staging. Period.
Strategy 3: Conditional policy with the Permission Framework
For a more advanced solution, you can write a custom policy that validates group access at runtime. This example verifies that the user belongs to a group with assigned namespaces before allowing scaffolder task creation:
// plugins/permission-backend-module-custom/src/policy.ts
import {
PolicyDecision,
AuthorizeResult,
} from '@backstage/plugin-permission-common';
import {
PermissionPolicy,
PolicyQuery,
} from '@backstage/plugin-permission-node';
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
// Group to allowed namespaces mapping
const NAMESPACE_POLICY: Record<string, string[]> = {
'group:default/team-payments': ['payments-dev', 'payments-staging'],
'group:default/team-frontend': ['frontend-dev', 'frontend-staging'],
'group:default/platform-team': ['*'], // Access to everything
};
export class CustomPermissionPolicy implements PermissionPolicy {
async handle(
request: PolicyQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
// Get user's groups
const userGroups = user?.identity.ownershipEntityRefs ?? [];
// If scaffolder.task.create, verify namespace
if (request.permission.name === 'scaffolder.task.create') {
const hasAccess = userGroups.some(group => {
const allowed = NAMESPACE_POLICY[group];
return allowed?.includes('*') || allowed?.length > 0;
});
return {
result: hasAccess
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
};
}
// For everything else, delegate to RBAC
return { result: AuthorizeResult.ALLOW };
}
}Step 7: Define namespaces in Kubernetes
For everything to fit together, you need the namespaces to exist in the cluster with the correct labels:
# namespaces.yaml
apiVersion: v1
kind: Namespace
metadata:
name: payments-dev
labels:
team: team-payments
environment: dev
managed-by: backstage
---
apiVersion: v1
kind: Namespace
metadata:
name: payments-staging
labels:
team: team-payments
environment: staging
managed-by: backstage
---
apiVersion: v1
kind: Namespace
metadata:
name: frontend-dev
labels:
team: team-frontend
environment: dev
managed-by: backstage
---
apiVersion: v1
kind: Namespace
metadata:
name: frontend-staging
labels:
team: team-frontend
environment: staging
managed-by: backstageApply them:
kubectl apply -f namespaces.yamlAnd if you use ArgoCD, you can restrict Applications to specific namespaces with AppProject:
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: team-payments
namespace: argocd
spec:
description: Project for the payments team
sourceRepos:
- "https://github.com/your-org/payments-*"
destinations:
- namespace: payments-dev
server: https://kubernetes.default.svc
- namespace: payments-staging
server: https://kubernetes.default.svc
# Block sensitive resources
namespaceResourceBlacklist:
- group: ""
kind: ResourceQuota
- group: ""
kind: LimitRange
- group: ""
kind: NetworkPolicyWith AppProject, ArgoCD rejects any attempt to deploy outside of payments-dev or payments-staging. It’s a second security layer that complements what Backstage does.
The complete flow
Here’s the end-to-end flow with all pieces connected:
Each step has its own validation:
- Backstage RBAC validates the user can execute the template.
- The template restricts available namespaces based on the team.
- ArgoCD AppProject validates the destination is an allowed namespace.
- Kubernetes RBAC (RoleBindings) limits what ArgoCD’s ServiceAccount can do in that namespace.
That’s four security layers, each one complementing the other.
Summary
| Layer | Tool | What it controls |
|---|---|---|
| 1. Template access | Backstage RBAC | Who can execute which template |
| 2. Allowed parameters | Golden Path Template | Which namespaces and environments are available |
| 3. Deploy destination | ArgoCD AppProject | Where each project can sync to |
| 4. Cluster permissions | Kubernetes RBAC | What operations each ServiceAccount can perform |
Golden Paths aren’t just “pretty templates”. They’re the way to codify your organization’s policies into a self-service workflow. The developer doesn’t need to know there are four security layers underneath — they just fill out a form and everything works.
That’s Platform Engineering: making the right thing the easiest thing to do.