Contents

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:

  1. Golden Paths — pre-defined paths that guide developers through the right workflow.
  2. 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:

ToolVersionWhat for
Backstagev1.49+Developer portal
Node.js22+Backstage runtime
Kubernetes1.28+Target cluster
kubectl1.28+Cluster access

If you’ve been following the Backstage series posts, you already have most of the setup ready.

Tip
This post is independent from the Backstage series, but if you want the full context, check out Series #1 for the initial installation.

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 environment field only offers dev and staging. Nobody can deploy to production from this template.
  • The namespace is controlled at the template level, not freely typed by the user.
  • The owner uses OwnerPicker which 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: 5

Notice 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:

  1. Permission: Represents a concrete action, like catalog.entity.read or scaffolder.task.create.
  2. Policy: The function that evaluates whether a user can perform that action. It can return ALLOW, DENY or CONDITIONAL.
  3. 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: true

Then install the permission module in the backend:

yarn workspace backend add @backstage/plugin-permission-backend-module-allow-all-policy
Important
The allow-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-backend

Replace 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.csv

Create 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, allow

This 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-payments

Notice: 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: backstage

Apply them:

kubectl apply -f namespaces.yaml

And 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: NetworkPolicy

With 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:

  1. Backstage RBAC validates the user can execute the template.
  2. The template restricts available namespaces based on the team.
  3. ArgoCD AppProject validates the destination is an allowed namespace.
  4. Kubernetes RBAC (RoleBindings) limits what ArgoCD’s ServiceAccount can do in that namespace.

That’s four security layers, each one complementing the other.


Summary

LayerToolWhat it controls
1. Template accessBackstage RBACWho can execute which template
2. Allowed parametersGolden Path TemplateWhich namespaces and environments are available
3. Deploy destinationArgoCD AppProjectWhere each project can sync to
4. Cluster permissionsKubernetes RBACWhat 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.


Resources