Contents

Backstage Series #2: Generate Kubernetes Manifests and Create PRs from Your Portal

Updated March 2026: This article uses Backstage v1.49+, the updated Kubernetes plugin @backstage/plugin-kubernetes, and autoscaling/v2 for HPA manifests.

What You’ll Need

This is the second installment of the Backstage series. Before getting started, make sure you’ve completed these two posts:

  1. Backstage Series #1: Getting Started with Backstage — installation and first template.
  2. Kubernetes: What It Is, What It’s For, and How to Set Up Your First Local Cluster — local cluster with Kind ready.

In the previous post, we created our Backstage instance and saw how Software Templates work. Today we’re connecting those two pieces: Backstage will talk to your Kubernetes cluster, and from a template you’ll be able to generate manifests and submit them as a Pull Request to a configuration repository.


Prerequisites

ToolVersionVerify
Backstagev1.49+yarn backstage-cli info
OrbStack (macOS) or Docker Desktop (Windows/Linux)Container runtime
Kindv0.20+kind version
kubectlv1.35+kubectl version --client
GitHub Tokenwith repo permissionsConfigured in app-config.yaml

Your Kind cluster should be running. If you followed the Kubernetes post, you already have OrbStack (macOS) or Docker Desktop (Windows/Linux) + Kind configured:

# Verify the cluster exists
kind get clusters

# If you don't have one, create it
kind create cluster --name mi-cluster

Step 1: Create a Namespace Manually

Before automating anything, let’s create a namespace by hand to understand what it is and what it’s for. A namespace in Kubernetes is like a folder that groups and isolates resources. It allows multiple teams or projects to coexist in the same cluster without stepping on each other.

# Create a namespace
kubectl create namespace staging

# Verify it was created
kubectl get namespaces

You should see something like:

NAME                 STATUS   AGE
default              Active   5d
kube-node-lease      Active   5d
kube-public          Active   5d
kube-system          Active   5d
staging              Active   3s
Tip

You can also create a namespace with a YAML manifest:

apiVersion: v1
kind: Namespace
metadata:
  name: staging
  labels:
    environment: staging

And apply it with kubectl apply -f namespace.yaml. This is what we’ll do later from Backstage.

Let’s create a couple more namespaces that we’ll use later:

kubectl create namespace development
kubectl create namespace production

Step 2: Install the Kubernetes Plugin in Backstage

Now let’s give Backstage eyes to see what’s inside your cluster. The Kubernetes plugin has two parts: frontend (what you see in the UI) and backend (the connection to the cluster).

Install Dependencies

# Frontend: adds the Kubernetes tab on entity pages
yarn --cwd packages/app add @backstage/plugin-kubernetes

# Backend: connects Backstage to your cluster
yarn --cwd packages/backend add @backstage/plugin-kubernetes-backend

Register the Backend

In packages/backend/src/index.ts, add the plugin:

const backend = createBackend();

// ... other plugins ...

backend.add(import('@backstage/plugin-kubernetes-backend'));

backend.start();

Add the UI Tab

In packages/app/src/components/catalog/EntityPage.tsx, add the Kubernetes tab to service pages:

import { EntityKubernetesContent } from '@backstage/plugin-kubernetes';

// Inside serviceEntityPage, add this route:
<EntityLayout.Route path="/kubernetes" title="Kubernetes">
  <EntityKubernetesContent refreshIntervalMs={30000} />
</EntityLayout.Route>

Step 3: Configure the Cluster Connection

Open your app-config.yaml and add the Kubernetes configuration:

kubernetes:
  serviceLocatorMethod:
    type: 'multiTenant'
  clusterLocatorMethods:
    - type: 'config'
      clusters:
        - url: https://127.0.0.1:<API_PORT>
          name: mi-cluster
          authProvider: 'serviceAccount'
          skipTLSVerify: true
          skipMetricsLookup: true
          serviceAccountToken: ${K8S_SERVICE_ACCOUNT_TOKEN}

Get the Token and URL

To connect to your Kind cluster you need a Service Account token and the API server URL:

# Get the API server URL
kubectl cluster-info
# Output: Kubernetes control plane is running at https://127.0.0.1:XXXXX

# Create a Service Account for Backstage
kubectl create serviceaccount backstage -n default

# Give it read permissions across the cluster
kubectl create clusterrolebinding backstage-reader \
  --clusterrole=view \
  --serviceaccount=default:backstage

# Generate a token
kubectl create token backstage -n default --duration=8760h

Copy the generated token and export it as an environment variable before starting Backstage:

export K8S_SERVICE_ACCOUNT_TOKEN="eyJhbGciOiJS..."

Verify the Connection

Start Backstage with yarn start and navigate to any catalog entity. You should see the Kubernetes tab available. If you have deployments running with the correct annotation, you’ll see the pods and their status.

For a catalog entity to show its Kubernetes resources, add these annotations to the catalog-info.yaml:

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: my-service
  annotations:
    backstage.io/kubernetes-id: my-service
    backstage.io/kubernetes-namespace: staging
spec:
  type: service
  lifecycle: production
  owner: platform-team

Step 4: Create the Software Template

This is where things get interesting. We’re going to create a Software Template that:

  1. Asks the user for: service name, namespace, Docker image, replicas, etc.
  2. Generates Kubernetes manifests (Deployment, Service, HPA).
  3. Opens a Pull Request in a configuration repository with the generated manifests.

Template Structure

templates/
└── k8s-deployment/
    ├── template.yaml          # Template definition
    └── skeleton/
        └── manifests/
            ├── deployment.yaml    # Deployment template
            ├── service.yaml       # Service template
            └── hpa.yaml           # HPA template

The Main Template: template.yaml

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: k8s-deployment-generator
  title: Generate Kubernetes Deployment
  description: |
    Generates Kubernetes manifests (Deployment, Service, HPA) and
    creates a Pull Request in the configuration repository.
  tags:
    - kubernetes
    - deployment
    - gitops
spec:
  owner: platform-team
  type: service

  parameters:
    - title: Service Information
      required:
        - serviceName
        - namespace
        - image
      properties:
        serviceName:
          title: Service Name
          type: string
          description: Name for the deployment and service in Kubernetes
          ui:autofocus: true
          pattern: '^[a-z0-9-]+$'
        namespace:
          title: Namespace
          type: string
          description: Namespace where the service will be deployed
          enum:
            - development
            - staging
            - production
          default: staging
        image:
          title: Docker Image
          type: string
          description: "Full image (e.g.: ghcr.io/my-org/my-app:latest)"
        containerPort:
          title: Container Port
          type: integer
          default: 8080

    - title: Scaling
      properties:
        replicas:
          title: Initial Replicas
          type: integer
          default: 2
          minimum: 1
          maximum: 10
        enableHPA:
          title: Enable Autoscaling (HPA)
          type: boolean
          default: true
        minReplicas:
          title: Minimum Replicas (HPA)
          type: integer
          default: 2
          minimum: 1
        maxReplicas:
          title: Maximum Replicas (HPA)
          type: integer
          default: 8
          minimum: 2
          maximum: 20
        cpuTarget:
          title: "Target CPU (%)"
          type: integer
          default: 50
          minimum: 10
          maximum: 90

    - title: Target Repository
      required:
        - repoUrl
      properties:
        repoUrl:
          title: Configuration Repository
          type: string
          ui:field: RepoUrlPicker
          ui:options:
            requestUserCredentials:
              secretsKey: USER_OAUTH_TOKEN
            allowedHosts:
              - github.com

  steps:
    - id: fetch-manifests
      name: Generate Kubernetes Manifests
      action: fetch:template
      input:
        url: ./skeleton
        targetPath: ./generated
        values:
          serviceName: ${{ parameters.serviceName }}
          namespace: ${{ parameters.namespace }}
          image: ${{ parameters.image }}
          containerPort: ${{ parameters.containerPort }}
          replicas: ${{ parameters.replicas }}
          enableHPA: ${{ parameters.enableHPA }}
          minReplicas: ${{ parameters.minReplicas }}
          maxReplicas: ${{ parameters.maxReplicas }}
          cpuTarget: ${{ parameters.cpuTarget }}

    - id: create-pr
      name: Create Pull Request
      action: publish:github:pull-request
      input:
        repoUrl: ${{ parameters.repoUrl }}
        branchName: feat/deploy-${{ parameters.serviceName }}-${{ parameters.namespace }}
        title: "feat: add K8s manifests for ${{ parameters.serviceName }} (${{ parameters.namespace }})"
        description: |
          ## Manifests generated by Backstage

          - **Service**: ${{ parameters.serviceName }}
          - **Namespace**: ${{ parameters.namespace }}
          - **Image**: ${{ parameters.image }}
          - **Replicas**: ${{ parameters.replicas }}
          - **HPA**: ${{ parameters.enableHPA }}

          Automatically generated from the Backstage portal.
        sourcePath: ./generated
        targetPath: services/${{ parameters.serviceName }}/
        token: ${{ secrets.USER_OAUTH_TOKEN }}

  output:
    links:
      - title: View Pull Request
        url: ${{ steps['create-pr'].output.remoteUrl }}
      - title: View in Catalog
        icon: catalog
        entityRef: ${{ steps['create-pr'].output.targetBranchName }}

The Skeleton Templates

These are the files processed with Nunjucks. The ${{ values.xxx }} variables are replaced with what the user entered in the form.

skeleton/manifests/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${{ values.serviceName }}
  namespace: ${{ values.namespace }}
  labels:
    app: ${{ values.serviceName }}
    managed-by: backstage
spec:
  replicas: ${{ values.replicas }}
  selector:
    matchLabels:
      app: ${{ values.serviceName }}
  template:
    metadata:
      labels:
        app: ${{ values.serviceName }}
    spec:
      containers:
        - name: ${{ values.serviceName }}
          image: ${{ values.image }}
          ports:
            - containerPort: ${{ values.containerPort }}
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"
          livenessProbe:
            httpGet:
              path: /health
              port: ${{ values.containerPort }}
            initialDelaySeconds: 15
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health
              port: ${{ values.containerPort }}
            initialDelaySeconds: 5
            periodSeconds: 5

skeleton/manifests/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: ${{ values.serviceName }}
  namespace: ${{ values.namespace }}
  labels:
    app: ${{ values.serviceName }}
    managed-by: backstage
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: ${{ values.containerPort }}
      protocol: TCP
  selector:
    app: ${{ values.serviceName }}

skeleton/manifests/hpa.yaml

{%- if values.enableHPA %}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: ${{ values.serviceName }}-hpa
  namespace: ${{ values.namespace }}
  labels:
    app: ${{ values.serviceName }}
    managed-by: backstage
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ${{ values.serviceName }}
  minReplicas: ${{ values.minReplicas }}
  maxReplicas: ${{ values.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: ${{ values.cpuTarget }}
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 70
{%- endif %}
About Nunjucks
Notice the {%- if values.enableHPA %} in the HPA file. If the user doesn’t check the autoscaling option, the file is generated empty. The - in {%- removes whitespace before the block, keeping the YAML clean.

Step 5: Register the Template in Backstage

Add the template location in your app-config.yaml:

catalog:
  locations:
    - type: file
      target: ../../templates/k8s-deployment/template.yaml
      rules:
        - allow: [Template]

Restart Backstage and go to Create… → Generate Kubernetes Deployment. You should see a form with three steps:

  1. Service Information: name, namespace, image, port.
  2. Scaling: replicas, HPA, CPU targets.
  3. Target Repository: where the PR will be opened.

Step 6: Test the Full Flow

Let’s test it end to end:

  1. Create a repository on GitHub called k8s-configs (or whatever name you prefer). This will be your manifests repo.

  2. Go to Backstage → Create → Generate Kubernetes Deployment.

  3. Fill in the form:

    • Service: api-users
    • Namespace: staging
    • Image: ghcr.io/my-org/api-users:v1.0.0
    • Port: 8080
    • Replicas: 2
    • HPA: enabled
    • Repository: github.com?repo=k8s-configs&owner=your-username
  4. Click Create. Backstage will execute the steps, generate the manifests, and open a PR in your repo.

  5. Review the PR on GitHub. You should see the structure:

services/
└── api-users/
    └── manifests/
        ├── deployment.yaml
        ├── service.yaml
        └── hpa.yaml

With the values you entered already interpolated in the YAML files.


The Full Flow with GitOps

So far you have a powerful flow: a developer fills a form in Backstage, a PR with Kubernetes manifests is generated, and someone on the team reviews and approves it. But… what happens after the merge?

That’s where ArgoCD and the GitOps concept come in.

The idea is this:

  1. Backstage generates the manifests and creates the PR (what we did today).
  2. The team reviews and approves the PR on GitHub.
  3. ArgoCD is watching the repository. When it detects a change on main, it automatically syncs the manifests with the Kubernetes cluster.
  4. The cluster updates without anyone running kubectl apply.

This is GitOps in its purest form: the repository is the source of truth. If you want to change something in the cluster, you change the repo. If you want to rollback, you revert the commit. The cluster state always reflects what’s in Git.

Next Post
In Backstage Series #3: Integrate GitHub, ArgoCD, and Kubernetes in Your Portal, we install the GitHub Actions and ArgoCD plugins in Backstage, and close the full loop: from a developer filling a form to the service running in Kubernetes — all visible from the portal.

Summary

StepWhat We Did
1Created namespaces manually in Kubernetes
2Installed the Kubernetes plugin in Backstage (frontend + backend)
3Configured the connection to the local cluster (Service Account + token)
4Created a Software Template that generates Deployment, Service, and HPA
5Registered the template and tested the form
6Generated manifests and opened a PR on GitHub from Backstage

Resources