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, andautoscaling/v2for 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:
- Backstage Series #1: Getting Started with Backstage — installation and first template.
- 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
| Tool | Version | Verify |
|---|---|---|
| Backstage | v1.49+ | yarn backstage-cli info |
| OrbStack (macOS) or Docker Desktop (Windows/Linux) | — | Container runtime |
| Kind | v0.20+ | kind version |
| kubectl | v1.35+ | kubectl version --client |
| GitHub Token | with repo permissions | Configured 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-clusterStep 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 namespacesYou 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 3sYou can also create a namespace with a YAML manifest:
apiVersion: v1
kind: Namespace
metadata:
name: staging
labels:
environment: stagingAnd 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 productionStep 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-backendRegister 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=8760hCopy 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-teamStep 4: Create the Software Template
This is where things get interesting. We’re going to create a Software Template that:
- Asks the user for: service name, namespace, Docker image, replicas, etc.
- Generates Kubernetes manifests (Deployment, Service, HPA).
- 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 templateThe 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: 5skeleton/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 %}{%- 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:
- Service Information: name, namespace, image, port.
- Scaling: replicas, HPA, CPU targets.
- Target Repository: where the PR will be opened.
Step 6: Test the Full Flow
Let’s test it end to end:
Create a repository on GitHub called
k8s-configs(or whatever name you prefer). This will be your manifests repo.Go to Backstage → Create → Generate Kubernetes Deployment.
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
- Service:
Click Create. Backstage will execute the steps, generate the manifests, and open a PR in your repo.
Review the PR on GitHub. You should see the structure:
services/
└── api-users/
└── manifests/
├── deployment.yaml
├── service.yaml
└── hpa.yamlWith 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:
- Backstage generates the manifests and creates the PR (what we did today).
- The team reviews and approves the PR on GitHub.
- ArgoCD is watching the repository. When it detects a change on
main, it automatically syncs the manifests with the Kubernetes cluster. - 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.
Summary
| Step | What We Did |
|---|---|
| 1 | Created namespaces manually in Kubernetes |
| 2 | Installed the Kubernetes plugin in Backstage (frontend + backend) |
| 3 | Configured the connection to the local cluster (Service Account + token) |
| 4 | Created a Software Template that generates Deployment, Service, and HPA |
| 5 | Registered the template and tested the form |
| 6 | Generated manifests and opened a PR on GitHub from Backstage |