Build a Mini Internal Developer Platform

Build a working Internal Developer Platform using Backstage - software catalog, golden path template that creates a new service end-to-end, TechDocs, and Kubernetes integration.

Related Concepts & TermsPATHStage

Domains & Technologies

Domains
CAPSTONEIDPBACKSTAGEGOLDEN-PATHSSOFTWARE-CATALOGTECHDOCSPLATFORM-ENGINEERINGSELF-SERVICE
Technologies

Blueprint Walkthrough

Before You Start — Read This First

A new engineer joins the Swiggy backend team. On day one, they need to create a new microservice. Without a developer platform, here is what actually happens:

They open a Slack message to the platform team asking how to create a repository. The platform team replies two hours later with a link to a 40-page confluence doc. The engineer follows the doc, but half the steps are out of date. They open three more tickets — one for a Kubernetes namespace, one for an ArgoCD Application, one for secrets access. They spend two weeks asking questions, waiting for approvals, and manually stitching things together. By the time the service is running, they have forgotten why they started.

With an Internal Developer Platform, the same engineer opens a web portal, fills out a form — service name, team, description — clicks Create, and gets back a fully configured GitHub repository, Kubernetes manifests, ArgoCD integration, and documentation scaffold. In ten minutes. Without talking to anyone.

This capstone builds that portal. You will use the services and infrastructure from Capstones 1, 2, and 3 as the foundation. By the end, every service ever created will be visible in one place with its ownership, deployment status, and documentation — and creating a new one will be a self-service form.

What you are building:

◈ DIAGRAM
Backstage Portal (http://localhost:3000)
├── Software Catalog
│ ├── Shows all services from Capstones 1-3
│ ├── Ownership, documentation, health status
│ └── Live Kubernetes deployment status per service
├── Golden Path Template
│ └── Developer fills a form → Backstage:
│ 1. Creates GitHub repository
│ 2. Adds Kubernetes manifests (Capstone 1 pattern)
│ 3. Adds ArgoCD Application manifest (Capstone 3 pattern)
│ 4. Registers service in the catalog
│ All in under 10 minutes, zero manual steps
└── TechDocs
└── Auto-rendered docs from Markdown in each service repo

After this capstone you will be able to:

  • Explain the difference between a developer platform and a developer portal
  • Install and configure Backstage with GitHub integration
  • Build a software catalog that auto-discovers services from GitHub
  • Add Kubernetes integration so developers see live pod status inside Backstage
  • Write a Golden Path Template that creates a full service end-to-end
  • Set up TechDocs to render docs-as-code from each repository

Time to complete: 4-5 hours.

What you need before starting:

  • Capstones 1, 2, and 3 completed (or their outputs available)
  • Node.js 18+ and Yarn installed locally
  • A GitHub account with a Personal Access Token (repo + workflow scopes)
  • The EKS cluster from Capstone 2 (or any Kubernetes cluster)

Part 1 — What Is an IDP and Why Are We Building One

The difference between platform and portal

The term Internal Developer Platform gets confused with Internal Developer Portal constantly. They are different things.

The platform is the engine — the set of tools, infrastructure, APIs, and automation that actually deploys and runs services. This is everything from Capstones 1-3: Kubernetes, Terraform, ArgoCD, Prometheus, Grafana. It exists whether or not any human looks at it.

The portal is the front door — the interface that developers use to interact with the platform. Backstage is a portal. It sits on top of the platform and makes it accessible to people who do not need to understand how it works underneath.

Think of it like a restaurant. The platform is the kitchen — the stoves, the chefs, the supply chain, the recipes. The portal is the menu and the waiter. You do not need to know how the kitchen works to order food. You just read the menu and make a choice.

Backstage is the menu. The goal of this capstone is to build a menu that is so good that engineers never need to go into the kitchen directly.

Platform-as-product mindset

The reason most internal platforms fail is that the platform team builds what they think developers need rather than what developers actually need. They create tools that are powerful but require expert knowledge to use. Then they wonder why nobody uses them.

Platform-as-product means treating your developer platform like a product with real users (internal developers) and measuring adoption, satisfaction, and time-to-first-deployment the same way a product team measures conversion rates. This capstone puts that mindset into practice. You are not building infrastructure — you are building a product that makes other engineers more productive.


Part 2 — Install and Configure Backstage

What `create-app` does

Backstage is a React and Node.js application that you run yourself. Unlike SaaS tools, you own the deployment. npx @backstage/create-app@latest scaffolds a complete Backstage application with a frontend, a backend API, and a plugin system ready for configuration.

The scaffolded app comes with a catalog, TechDocs, and a basic template plugin pre-installed. You will configure them and add the Kubernetes plugin in later parts.

Bash
## Scaffold a new Backstage application
## This downloads the latest Backstage app template and sets up the project
npx @backstage/create-app@latest
## When prompted:
## App name: devops-network-portal
## Database: SQLite (for local development — use PostgreSQL in production)
cd devops-network-portal
## Install all dependencies
yarn install
## Verify the app starts
yarn dev
## Opens http://localhost:3000 (frontend) and http://localhost:7007 (backend)
Tip

The first yarn dev takes 2-3 minutes to compile. After that, hot reload is fast. If you see a blank page, wait 30 seconds and refresh.

Configure `app-config.yaml`

The app-config.yaml file at the project root controls everything: authentication, GitHub integration, the Kubernetes plugin, and TechDocs. Here is the complete configuration for this capstone.

YAML
## app-config.yaml
## Every section explained — do not skip the comments
app:
title: DevOps Network Portal
baseUrl: http://localhost:3000 ## change to your domain in production
backend:
baseUrl: http://localhost:7007
listen:
port: 7007
## Database — SQLite for local dev, switch to PostgreSQL for production
database:
client: better-sqlite3
connection: ':memory:'
cors:
origin: http://localhost:3000
methods: [GET, HEAD, PATCH, POST, PUT, DELETE]
credentials: true
## GitHub integration — reads catalog files and creates repositories
integrations:
github:
- host: github.com
## Create a Personal Access Token at https://github.com/settings/tokens
## Scopes needed: repo, workflow, read:org
token: ${GITHUB_TOKEN} ## read from environment variable — never hardcode
## Proxy — used by some plugins to reach external APIs securely
proxy:
'/prometheus/api':
target: http://monitoring-kube-prometheus-prometheus.monitoring.svc:9090
## TechDocs — docs-as-code, rendered from Markdown in each repo
techdocs:
builder: 'local' ## 'local' builds docs in Backstage; 'external' uses CI
generator:
runIn: 'docker' ## uses mkdocs inside Docker to build docs
publisher:
type: 'local' ## stores built docs locally; use GCS/S3 in production
## Authentication — GitHub OAuth so developers log in with their GitHub account
auth:
providers:
github:
development:
clientId: ${AUTH_GITHUB_CLIENT_ID}
clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
## Catalog — where Backstage discovers services
catalog:
import:
entityFilename: catalog-info.yaml ## filename Backstage looks for in repos
pullRequestBranchName: backstage-integration
rules:
## Allow these entity types to be registered in the catalog
- allow: [Component, System, API, Resource, Location, Group, User, Template, Domain]
## GitHub Discovery — scans all repositories for catalog-info.yaml
## This is the key setting that keeps the catalog up to date automatically
providers:
github:
devops-network-org:
organization: 'your-github-org' ## replace with your GitHub org name
catalogPath: '/catalog-info.yaml' ## file Backstage looks for in each repo
filters:
branch: 'main' ## only scan the main branch
repository: '.*' ## match all repositories
schedule:
frequency: { minutes: 30 } ## scan every 30 minutes
timeout: { minutes: 3 }
## Kubernetes plugin — shows live cluster status inside each service page
kubernetes:
serviceLocatorMethod:
type: 'multiTenant'
clusterLocatorMethods:
- type: 'config'
clusters:
- url: ${K8S_CLUSTER_URL} ## your EKS cluster API server URL
name: production-eks
authProvider: 'serviceAccount'
skipTLSVerify: false
serviceAccountToken: ${K8S_SERVICE_ACCOUNT_TOKEN}
caData: ${K8S_CA_DATA} ## base64-encoded cluster CA cert
Remember

Never put secrets directly in app-config.yaml. Use environment variables (${VARIABLE_NAME}) and a .env file locally. In production, use Kubernetes Secrets or AWS Parameter Store.

Set environment variables

Bash
## Create a .env file for local development
## Add .env to .gitignore — never commit this file
cat > .env << 'EOF'
GITHUB_TOKEN=ghp_your_personal_access_token_here
AUTH_GITHUB_CLIENT_ID=your_oauth_app_client_id
AUTH_GITHUB_CLIENT_SECRET=your_oauth_app_client_secret
K8S_CLUSTER_URL=https://your-eks-api-server.ap-south-1.eks.amazonaws.com
K8S_SERVICE_ACCOUNT_TOKEN=eyJhbGciOiJSUzI1NiJ9...
K8S_CA_DATA=LS0tLS1CRUdJTi...
EOF
## Load env vars when starting the app
export $(cat .env | xargs) && yarn dev

Part 3 — Build the Software Catalog

What the catalog is and why it matters

The software catalog is a centralized inventory of every service, API, database, and team in your organization. Without it, answering questions like "who owns the payment service?", "what services depend on Redis?", or "which team should I contact when the order API is down?" requires Slack messages, digging through GitHub, or tribal knowledge.

With a catalog, every service has a page showing its owner, dependencies, documentation, deployment status, and on-call contact — all in one place. At Razorpay, this means when a payment service incident fires at 2 AM, the on-call engineer can immediately see which team owns it, what it depends on, and jump directly to the runbook.

Register the swiggy-clone backend from Capstone 1

The catalog reads from a file called catalog-info.yaml that lives in each repository. Here is the complete file for the backend service from Capstone 1.

YAML
## catalog-info.yaml
## Place this file at the root of the swiggy-clone repository
## Backstage reads this to register the service in the catalog
apiVersion: backstage.io/v1alpha1
kind: Component ## Component = a deployable unit (service, library, website)
metadata:
name: swiggy-clone-backend
description: Node.js backend API for the swiggy-clone application
annotations:
## Links this component to the GitHub repository
github.com/project-slug: your-org/swiggy-clone
## Links this component to Kubernetes resources
## Backstage will show pods, deployments, and health for this label selector
backstage.io/kubernetes-label-selector: 'app=backend'
## Links to TechDocs — the docs/ folder in this repo
backstage.io/techdocs-ref: dir:.
## Links to Prometheus metrics for this service
prometheus.io/alert: 'All'
tags:
- nodejs
- backend
- api
- postgresql
links:
- url: https://swiggy-clone.your-domain.com/api
title: Production API
icon: web
- url: https://grafana.your-domain.com/d/backend
title: Grafana Dashboard
icon: dashboard
spec:
type: service ## type can be: service, library, website, documentation
lifecycle: production ## lifecycle can be: production, experimental, deprecated
owner: group:backend-team ## the team that owns this service
## Services this component depends on
dependsOn:
- resource:default/swiggy-postgres
- resource:default/swiggy-redis
- component:default/swiggy-clone-frontend
## APIs this component provides
providesApis:
- swiggy-clone-api
YAML
## catalog-info.yaml for the team that owns the service
## Usually lives in a central 'org-catalog' repository
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: backend-team
description: The team that builds and operates the swiggy-clone backend
spec:
type: team
profile:
displayName: Backend Team
email: backend-team@devops-network.in
parent: engineering ## parent group (department)
children: [] ## sub-teams, if any
members:
- rahul-sharma
- priya-nair
- arjun-kumar
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: swiggy-postgres
description: PostgreSQL database for the swiggy-clone service
annotations:
backstage.io/managed-by-location: url:https://github.com/your-org/swiggy-clone/blob/main/catalog-info.yaml
spec:
type: database
owner: group:backend-team
dependencyOf:
- component:default/swiggy-clone-backend

Configure GitHub auto-discovery

Manually registering every service by copying catalog-info.yaml URLs does not scale. Auto-discovery scans your entire GitHub organization on a schedule and registers any repository that has a catalog-info.yaml file.

Bash
## Install the GitHub discovery provider plugin
cd packages/backend
yarn add @backstage/plugin-catalog-backend-module-github
## Register the plugin in packages/backend/src/index.ts
## Add this line to the existing plugin registration section:
TYPESCRIPT
// packages/backend/src/index.ts
// Add this import at the top
import { GithubEntityProvider } from '@backstage/plugin-catalog-backend-module-github';
// In the createRouter function, add the provider:
const builder = await CatalogBuilder.create(env);
// This line enables auto-discovery from GitHub
// It reads the 'catalog.providers.github' section from app-config.yaml
builder.addEntityProvider(
GithubEntityProvider.fromConfig(env.config, {
logger: env.logger,
scheduler: env.scheduler, ## runs the scan on the schedule you configured
}),
);

Verify: find a service in the catalog

Bash
## Start Backstage and verify the catalog is working
export $(cat .env | xargs) && yarn dev
## Open http://localhost:3000/catalog
## You should see the swiggy-clone-backend component appear
## Click it — you should see:
## - Overview tab: owner, lifecycle, dependencies
## - CI/CD tab: empty for now (add GitHub Actions integration later)
## - Kubernetes tab: live pod status (after Part 4)
## - Docs tab: TechDocs (after Part 6)
## If the service does not appear after 2 minutes:
## Check the backend logs — look for GitHub discovery errors
## Verify the catalog-info.yaml is on the main branch of the repository

Part 4 — Add Kubernetes Integration

What the Kubernetes plugin does

Without the Kubernetes plugin, a developer who wants to check if their service is healthy needs to either ask the platform team for kubectl access or open a terminal and run commands. This is a barrier. Most developers do not have or want cluster access — they just want to know if their pods are running.

The Kubernetes plugin in Backstage shows live deployment status, pod count, replica health, and recent events for every service — directly on the service's catalog page. No kubectl. No cluster access required. The developer sees exactly what they need: is it running, how many replicas, any crashes?

Install the Kubernetes plugin

Bash
## Install the frontend plugin (shows Kubernetes data on catalog pages)
cd packages/app
yarn add @backstage/plugin-kubernetes
## Install the backend plugin (fetches data from the cluster)
cd packages/backend
yarn add @backstage/plugin-kubernetes-backend
TYPESCRIPT
// packages/app/src/components/catalog/EntityPage.tsx
// Add the Kubernetes tab to the service entity page
import {
EntityKubernetesContent,
isKubernetesAvailable,
} from '@backstage/plugin-kubernetes';
// Find the serviceEntityPage const and add this tab:
const serviceEntityPage = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
{overviewContent}
</EntityLayout.Route>
{/* This tab appears only if the component has kubernetes annotations */}
<EntityLayout.Route
path="/kubernetes"
title="Kubernetes"
if={isKubernetesAvailable}
>
<EntityKubernetesContent refreshIntervalMs={30000} />
</EntityLayout.Route>
<EntityLayout.Route path="/docs" title="Docs">
<EntityTechdocsContent />
</EntityLayout.Route>
</EntityLayout>
);
TYPESCRIPT
// packages/backend/src/index.ts
// Register the Kubernetes backend plugin
import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend';
// Add inside the createRouter function:
const { router: kubernetesRouter } = await KubernetesBuilder.createRouter({
logger: env.logger,
config: env.config,
permissions: env.permissions,
});
apiRouter.use('/kubernetes', kubernetesRouter);

Create a service account for Backstage to read the cluster

Backstage needs read-only access to your EKS cluster to show pod status. Never give it admin access.

YAML
## k8s/backstage-reader.yaml
## Apply this to your EKS cluster from Capstone 2
apiVersion: v1
kind: ServiceAccount
metadata:
name: backstage-reader
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: backstage-reader
rules:
## Read-only access to the resources Backstage needs to display
- apiGroups: [""]
resources: ["pods", "services", "endpoints", "namespaces", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["get", "list", "watch"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get", "list", "watch"]
- apiGroups: ["argoproj.io"] ## Argo Rollouts status from Capstone 3
resources: ["rollouts"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: backstage-reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: backstage-reader
subjects:
- kind: ServiceAccount
name: backstage-reader
namespace: default
Bash
## Apply the RBAC configuration
kubectl apply -f k8s/backstage-reader.yaml
## Extract the service account token for app-config.yaml
## Kubernetes 1.24+ requires explicit token creation
kubectl apply -f - << 'EOF'
apiVersion: v1
kind: Secret
metadata:
name: backstage-reader-token
namespace: default
annotations:
kubernetes.io/service-account.name: backstage-reader
type: kubernetes.io/service-account-token
EOF
## Get the token and CA data
K8S_SERVICE_ACCOUNT_TOKEN=$(kubectl get secret backstage-reader-token \
-o jsonpath='{.data.token}' | base64 -d)
K8S_CA_DATA=$(kubectl get secret backstage-reader-token \
-o jsonpath='{.data.ca\.crt}')
K8S_CLUSTER_URL=$(kubectl cluster-info | grep 'Kubernetes control plane' | awk '{print $NF}')
echo "Token: ${K8S_SERVICE_ACCOUNT_TOKEN}"
echo "CA Data: ${K8S_CA_DATA}"
echo "Cluster URL: ${K8S_CLUSTER_URL}"
## Add these to your .env file

What developers see

After this part is complete, a developer who opens the swiggy-clone-backend page in Backstage will see a Kubernetes tab showing:

  • Live pod count: 5/5 Running
  • Deployment health: Healthy
  • Most recent pod start times
  • Any pods in CrashLoopBackOff or OOMKilled state highlighted in red
  • The current container image tag — so they know which version is deployed

They get visibility without needing cluster access. Platform engineers control what can be seen and done through RBAC. This is the principle of least privilege applied to developer experience.


Part 5 — Build the Golden Path Template

What a Golden Path Template is

A Golden Path Template is a pre-approved, opinionated way to do something. In Backstage, it is a form that a developer fills out, and clicking Create triggers a sequence of automated steps — creating a repository, adding configuration files, registering the service in the catalog.

The name comes from a concept used at Netflix and Spotify: instead of telling developers "you can do anything," you create a golden path — a smooth, well-paved road that handles 80% of use cases perfectly. Developers can go off-road if they need to, but the golden path is so good that most do not need to.

This template will create a production-ready Node.js service that follows every pattern from Capstones 1-3. A developer fills in four fields. Ten minutes later, their service is in GitHub, in the catalog, and ArgoCD is watching it.

The Software Template YAML

YAML
## templates/nodejs-microservice/template.yaml
## This file lives in your GitOps repository from Capstone 3
## It defines the form AND the automation steps
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: nodejs-microservice
title: Node.js Microservice
description: Creates a production-ready Node.js service with Kubernetes, ArgoCD, and TechDocs configured
tags:
- nodejs
- kubernetes
- gitops
- recommended ## shows "recommended" badge in the template gallery
spec:
owner: group:platform-team
type: service
## ─── Step 1: The form developers fill in ─────────────────────
parameters:
- title: Service Details
required:
- name
- description
- owner
properties:
name:
title: Service Name
type: string
description: Lowercase, hyphen-separated (e.g. payment-service)
pattern: '^[a-z][a-z0-9-]*[a-z0-9]$' ## validates the input format
ui:autofocus: true
description:
title: Description
type: string
description: What does this service do? (shown in the catalog)
owner:
title: Owner Team
type: string
description: The team that owns this service
ui:field: OwnerPicker ## shows a dropdown of teams from the catalog
ui:options:
allowedKinds:
- Group
- title: Infrastructure Settings
required:
- namespace
- replicas
properties:
namespace:
title: Kubernetes Namespace
type: string
description: Namespace to deploy into (e.g. payments-production)
default: default
replicas:
title: Initial Replica Count
type: integer
description: How many pods to start with
default: 2
minimum: 1
maximum: 10
environment:
title: Target Environment
type: string
description: Which environment is this service for?
enum:
- staging
- production
default: staging
## ─── Step 2: The automation steps ─────────────────────────────
steps:
## Step 2a: Copy the skeleton files into the new repository
## ${{ parameters.name }} substitutes the service name the developer typed
- id: fetch-template
name: Fetch Template Files
action: fetch:template
input:
url: ./skeleton ## the skeleton/ directory next to this template.yaml
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
namespace: ${{ parameters.namespace }}
replicas: ${{ parameters.replicas }}
destination: ${{ parameters.repoUrl | parseRepoUrl }}
## Step 2b: Create the GitHub repository and push the skeleton files
- id: publish-github
name: Create GitHub Repository
action: publish:github
input:
allowedHosts: ['github.com']
description: ${{ parameters.description }}
repoUrl: github.com?owner=your-org&repo=${{ parameters.name }}
repoVisibility: private
defaultBranch: main
## Pre-populate the repository with topics for discoverability
topics:
- nodejs
- kubernetes
- ${{ parameters.environment }}
## Step 2c: Register the new service in the Backstage catalog
- id: register-catalog
name: Register in Catalog
action: catalog:register
input:
## The catalog-info.yaml created by the skeleton in the new repo
repoContentsUrl: ${{ steps['publish-github'].output.repoContentsUrl }}
catalogInfoPath: '/catalog-info.yaml'
## ─── Step 3: Links shown after creation ─────────────────────────
output:
links:
- title: GitHub Repository
url: ${{ steps['publish-github'].output.remoteUrl }}
- title: Open in Catalog
icon: catalog
entityRef: ${{ steps['register-catalog'].output.entityRef }}

The skeleton directory

The skeleton is the set of files that get copied into the new repository. Template variables like ${{ values.name }} are replaced with the values the developer entered in the form.

YAML
## templates/nodejs-microservice/skeleton/catalog-info.yaml
## Pre-filled with the values from the form
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
description: ${{ values.description }}
annotations:
github.com/project-slug: your-org/${{ values.name }}
backstage.io/kubernetes-label-selector: 'app=${{ values.name }}'
backstage.io/techdocs-ref: dir:.
tags:
- nodejs
- backend
spec:
type: service
lifecycle: experimental ## new services start as experimental
owner: ${{ values.owner }}
YAML
## templates/nodejs-microservice/skeleton/k8s/deployment.yaml
## Follows the exact pattern from Capstone 1
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${{ values.name }}
namespace: ${{ values.namespace }}
labels:
app: ${{ values.name }}
app.kubernetes.io/managed-by: backstage-golden-path
spec:
replicas: ${{ values.replicas }}
selector:
matchLabels:
app: ${{ values.name }}
template:
metadata:
labels:
app: ${{ values.name }}
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "4000"
prometheus.io/path: "/metrics"
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
containers:
- name: ${{ values.name }}
image: ghcr.io/your-org/${{ values.name }}:latest ## CI fills the tag
ports:
- containerPort: 4000
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /health/live
port: 4000
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 4000
initialDelaySeconds: 10
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: ${{ values.name }}-service
namespace: ${{ values.namespace }}
spec:
selector:
app: ${{ values.name }}
ports:
- name: http
port: 4000
targetPort: 4000
YAML
## templates/nodejs-microservice/skeleton/k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ${{ values.name }}-hpa
namespace: ${{ values.namespace }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: ${{ values.name }}
minReplicas: ${{ values.replicas }}
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
YAML
## templates/nodejs-microservice/skeleton/argocd/application.yaml
## ArgoCD Application manifest — follows the Capstone 3 pattern
## Once this file is in the repo, ArgoCD auto-deploys the service
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ${{ values.name }}-${{ values.environment }}
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/your-org/${{ values.name }}
targetRevision: HEAD
path: k8s/
destination:
server: https://kubernetes.default.svc
namespace: ${{ values.namespace }}
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Dockerfile
## templates/nodejs-microservice/skeleton/Dockerfile
## Multi-stage build — follows the Capstone 1 pattern
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS production
## Create non-root user for security (matches runAsUser: 1001 in k8s manifest)
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 4000
CMD ["node", "src/index.js"]
MARKDOWN
<!-- templates/nodejs-microservice/skeleton/docs/index.md -->
# ${{ values.name }}
${{ values.description }}
## Overview
This service was created using the DevOps Network golden path template.
## Architecture
<!-- Add architecture diagram here -->
## API Reference
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health/live` | GET | Liveness probe — returns 200 if the process is running |
| `/health/ready` | GET | Readiness probe — returns 200 if the service can handle traffic |
| `/metrics` | GET | Prometheus metrics endpoint |
## Running Locally
```bash
npm install
npm run dev

Deployment

This service is deployed automatically via ArgoCD. To deploy a new version:

  1. Push to main branch
  2. CI builds and pushes the Docker image
  3. Update the image tag in k8s/deployment.yaml
  4. ArgoCD detects the change and deploys within 3 minutes

Ownership

Owner: ${{ values.owner }}

YAML
### Register the template in Backstage
```yaml
## catalog-info.yaml in the templates repository root
## This registers all templates so Backstage shows them in Create > Templates
apiVersion: backstage.io/v1alpha1
kind: Location
metadata:
name: devops-network-templates
description: Golden path templates for DevOps Network
spec:
targets:
- ./nodejs-microservice/template.yaml
## add more templates here as you build them
Bash
## Register the templates location in Backstage
## Open http://localhost:3000/catalog-import
## Paste the URL of the catalog-info.yaml above:
## https://github.com/your-org/backstage-templates/blob/main/catalog-info.yaml
## Click Analyze → Import
## Or register via CLI:
curl -X POST http://localhost:7007/api/catalog/locations \
-H "Content-Type: application/json" \
-d '{"type": "url", "target": "https://github.com/your-org/backstage-templates/blob/main/catalog-info.yaml"}'

The complete developer experience

Here is exactly what a developer sees and does:

  1. Open http://localhost:3000 and sign in with GitHub
  2. Click Create in the left sidebar
  3. See the Node.js Microservice template with a "Recommended" badge
  4. Click Choose on the template
  5. Fill in the form: Service Name = notification-service, Description = Sends push notifications for order updates, Owner = backend-team, Namespace = notifications-staging, Replicas = 2
  6. Click Review — see a summary of what will be created
  7. Click Create
  8. Watch the steps complete in real time: Fetch Template → Create GitHub Repository → Register in Catalog
  9. Click Open in Catalog — the notification-service page is live in Backstage
  10. Click GitHub Repository — the repository exists with all files populated
  11. Wait 3 minutes — ArgoCD detects the argocd/application.yaml and starts deploying

The developer never touched a terminal, never spoke to the platform team, never opened a Kubernetes manifest manually. This is the goal.


Part 6 — TechDocs

What docs-as-code means

Docs-as-code is the practice of writing documentation in Markdown files that live in the same repository as the service's source code. The documentation is reviewed in pull requests, versioned with Git, and deployed the same way the code is.

The problem TechDocs solves is not that documentation is hard to write — it is that documentation is always out of date. When documentation lives in Confluence or Notion, it drifts away from the code. The service changes, the docs do not. With docs-as-code, when a developer changes the API, they update docs/index.md in the same pull request. The docs stay current because updating them is part of the same workflow as changing the code.

Configure TechDocs

YAML
## mkdocs.yml
## Place this file at the root of each service repository
## mkdocs is the tool that converts Markdown to HTML
site_name: '${{ values.name }} Documentation'
site_description: 'Technical documentation for ${{ values.name }}'
## TechDocs requires this exact plugin declaration
plugins:
- techdocs-core
## Document navigation structure
nav:
- Home: index.md
- Architecture: architecture.md
- API Reference: api.md
- Runbooks:
- Deployment: runbooks/deployment.md
- Incident Response: runbooks/incident-response.md
## These extensions enable useful Markdown features
markdown_extensions:
- admonition ## note/warning/tip blocks
- pymdownx.details ## collapsible sections
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences ## code blocks with line highlighting
MARKDOWN
<!-- docs/index.md -->
<!-- This is the TechDocs home page for the service -->
<!-- It is rendered inside Backstage on the service's Docs tab -->
# notification-service
Sends push notifications for order updates, delivery status changes, and
promotional messages to Swiggy app users.
## Quick Links
- [Production Grafana Dashboard](https://grafana.swiggy.internal/d/notifications)
- [GitHub Repository](https://github.com/swiggy-eng/notification-service)
- [On-Call Runbook](runbooks/incident-response.md)
## Architecture
The notification service receives events from the order-service via a Kafka
topic, processes them, and dispatches to FCM (Firebase Cloud Messaging) for
Android and APNs for iOS.
## SLOs
| SLO | Target | Current |
|-----|--------|---------|
| Notification delivery rate | 99.5% | 99.8% |
| P95 delivery latency | < 5 seconds | 3.2 seconds |
| Error rate | < 0.1% | 0.04% |

Register TechDocs in the backend

TYPESCRIPT
// packages/backend/src/plugins/techdocs.ts
// This is the standard TechDocs backend setup
import {
createRouter,
Generators,
Preparers,
Publisher,
} from '@backstage/plugin-techdocs-backend';
export default async function createPlugin(env) {
const preparers = await Preparers.fromConfig(env.config, {
logger: env.logger,
reader: env.reader,
});
const generators = await Generators.fromConfig(env.config, {
logger: env.logger,
containerRunner: env.containerRunner,
});
const publisher = await Publisher.fromConfig(env.config, {
logger: env.logger,
discovery: env.discovery,
});
// Builds docs on first view if not already built
await publisher.getReadiness();
return await createRouter({
preparers,
generators,
publisher,
logger: env.logger,
config: env.config,
discovery: env.discovery,
cache: env.cache,
});
}
Tip

The local builder builds docs inside Docker when a developer first visits the Docs tab. In production, pre-build docs in CI and publish to GCS or S3 using the external builder. This makes the first page load instant.


Part 7 — Production Checklist
Bash
## ─── 1. Backstage starts without errors ─────────────────────────
yarn dev 2>&1 | grep -E '(error|warn|Started|listening)'
## Should show: Backend started, Listening on port 7007
## Should NOT show: Error connecting to GitHub, Module not found
## ─── 2. Catalog has registered the swiggy-clone services ────────
curl -s http://localhost:7007/api/catalog/entities?kind=Component | \
jq '.[].metadata.name'
## Should include: swiggy-clone-backend, swiggy-clone-frontend
## ─── 3. GitHub discovery is running ─────────────────────────────
curl -s http://localhost:7007/api/catalog/entities?kind=Location | \
jq '.[].metadata.name'
## Should show locations including github-discovery entries
## ─── 4. Kubernetes plugin can reach the cluster ──────────────────
curl -s http://localhost:7007/api/kubernetes/proxy/api/v1/namespaces \
-H "Authorization: Bearer ${K8S_SERVICE_ACCOUNT_TOKEN}" | \
jq '.items[].metadata.name'
## Should list namespaces including swiggy-clone-staging
## ─── 5. TechDocs builds successfully ────────────────────────────
## Navigate to the swiggy-clone-backend page in Backstage
## Click the Docs tab
## Should render the docs/index.md content as HTML
## If it shows "No docs", check mkdocs.yml is at repo root
## ─── 6. Golden Path Template is visible ─────────────────────────
curl -s http://localhost:7007/api/catalog/entities?kind=Template | \
jq '.[].metadata.name'
## Should include: nodejs-microservice
## ─── 7. Test the template end-to-end ────────────────────────────
## Open http://localhost:3000/create
## Choose nodejs-microservice
## Fill form with: name=test-service, owner=backend-team, namespace=test, replicas=1
## Click Create — watch all 3 steps complete in the Backstage task log
## Verify the GitHub repository was created
curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
https://api.github.com/repos/your-org/test-service | \
jq '.name, .visibility, .default_branch'
## Should return: "test-service", "private", "main"
## Verify all skeleton files are present in the new repo
curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
https://api.github.com/repos/your-org/test-service/contents | \
jq '.[].name'
## Should include: catalog-info.yaml, Dockerfile, mkdocs.yml, docs/, k8s/, argocd/
## Verify the service appeared in the Backstage catalog
curl -s http://localhost:7007/api/catalog/entities/by-name/component/default/test-service | \
jq '.metadata.name, .spec.owner, .spec.lifecycle'
## Should return: "test-service", "group:backend-team", "experimental"
## Verify ArgoCD Application was created in the cluster
kubectl get application test-service-staging -n argocd \
-o jsonpath='{.metadata.name}{"\t"}{.spec.source.repoURL}{"\n"}'
## Should show: test-service-staging https://github.com/your-org/test-service
## Cleanup test resources after verification
argocd app delete test-service-staging --cascade
kubectl delete namespace test 2>/dev/null || true
echo "✅ Production checklist complete"

Common Production Mistakes

Building the IDP without talking to developers first 💥 The platform team spends three months building a perfect Backstage installation with 12 plugins. On launch day, adoption is near zero. Nobody knew it was being built. The templates don't match how teams actually create services. Engineers continue doing things the old way because the new way doesn't fit their mental model. ✅ Start with 5 developer interviews before writing a line of code. Ask: "What is the most annoying part of creating a new service?" Build that one thing first. Launch fast, iterate based on real feedback. An imperfect IDP that developers actually use beats a perfect IDP that nobody opens.


Manually maintaining the software catalog 💥 Three engineers spend one hour per week updating catalog-info.yaml files manually. New services get added to GitHub but not to the catalog. Old services get deleted from Kubernetes but their catalog entries stay. Within two months, the catalog is 30% stale. Developers stop trusting it. It becomes a liability instead of an asset. ✅ Auto-discovery is non-negotiable. Configure the GitHub discovery provider to scan on a schedule. Every service that has a catalog-info.yaml gets registered automatically. Every service that removes it gets deleted from the catalog. Manual maintenance should be zero.


Making templates too opinionated — zero customisation allowed 💥 The golden path template forces every service to use PostgreSQL, Node.js 18, and port 4000. A team building a Python data pipeline needs none of these. They cannot modify the template because the platform team "doesn't want to support variations." The team builds their service completely manually, bypassing every safeguard the golden path was supposed to provide. Now the platform team has less visibility into this service, not more. ✅ Templates should enforce the 20% that is truly non-negotiable (security context, resource limits, readiness probes, catalog registration) and let teams choose the 80% that varies (language, port, database). Add a customParameters section and provide multiple templates for different service archetypes.


No ownership enforcement in the catalog 💥 The catalog shows 47 services. Twelve of them have owner: unknown because they were registered before teams filled in ownership. When the order-service has an incident at 3 AM, PagerDuty cannot route the alert to the right team because the catalog says the owner is unknown. The platform team has to manually investigate who owns the service while the incident is ongoing. ✅ Add a Backstage Lint rule that fails catalog registration if spec.owner is missing or points to a non-existent group. Add a weekly automated report that lists services with invalid ownership. Make fixing ownership a requirement before a service can be marked lifecycle: production.


Debugging Playbook

Catalog not showing services — GitHub discovery not running:

Bash
## Check the Backstage backend logs for GitHub errors
yarn dev 2>&1 | grep -i 'github\|catalog\|discovery'
## Common error: "Not Found — GET /orgs/your-org/repos"
## Cause: GitHub token does not have read:org scope
## Fix: regenerate the token with read:org + repo scopes
## Common error: "Could not read entity at path"
## Cause: catalog-info.yaml has YAML syntax errors
## Fix: validate the file
npx js-yaml catalog-info.yaml ## exits 0 if valid, prints error if not
## Force an immediate catalog refresh (do not wait for the 30-minute schedule)
curl -X POST http://localhost:7007/api/catalog/refresh \
-H "Content-Type: application/json" \
-d '{"entityRef": "location:default/devops-network-org"}'

Template failing during GitHub repository creation:

Bash
## Check the Scaffolder logs in Backstage
## Open http://localhost:3000/create/tasks — shows all template runs and their logs
## Common error: "Resource not accessible by integration"
## Cause: GitHub token lacks 'workflow' scope (needed to create repos with Actions)
## Fix: add workflow scope to the Personal Access Token
## Common error: "Repository already exists"
## Cause: template was run before and the repo was not deleted
## Fix: delete the existing repo or add a unique suffix to the name
## Test template skeleton rendering without GitHub
## This verifies template variable substitution before running the real steps
npx @backstage/create-app -- --skip-install \
--template-path ./templates/nodejs-microservice/skeleton \
--values '{"name":"test-service","namespace":"default","replicas":2}'

Kubernetes plugin showing no data:

Bash
## Step 1: Verify the service account can read pods
kubectl auth can-i list pods \
--as=system:serviceaccount:default:backstage-reader \
-n swiggy-clone-staging
## Should print: yes
## Step 2: Verify the label selector matches actual pods
## The catalog-info.yaml annotation was: app=backend
kubectl get pods -n swiggy-clone-staging -l app=backend
## Should list pods — if empty, the label selector is wrong
## Step 3: Check the Kubernetes plugin configuration
## Open any service page → Kubernetes tab → look for error messages
## Common: "No resources found" = label selector mismatch
## Common: "Unauthorized" = service account token expired
## Step 4: Regenerate the service account token if expired
kubectl delete secret backstage-reader-token -n default
kubectl apply -f k8s/backstage-reader.yaml
## Then update K8S_SERVICE_ACCOUNT_TOKEN in .env

What You Have Built

Four capstones. Four layers of a complete platform engineering stack.

Bash
✅ Software Catalog — every service visible, owned, and linked
✅ GitHub Auto-Discovery — catalog stays current automatically
✅ Kubernetes Integration — live pod status without kubectl access
✅ Golden Path Template — new service in 10 minutes, zero manual steps
✅ ArgoCD Integration — template auto-creates GitOps delivery (Capstone 3)
✅ TechDocs — documentation lives next to code, always current

Here is how all four capstones connect:

  • Capstone 1 — you built the application
  • Capstone 2 — you provisioned the infrastructure for it to run on
  • Capstone 3 — you built the GitOps delivery platform
  • Capstone 4 — you wrapped everything in a developer experience layer

A new engineer at your company can now open the Backstage portal, click Create, fill in a form, and in ten minutes have a fully deployed, Prometheus-monitored, GitOps-managed, catalog-registered service — without knowing anything about Kubernetes, Terraform, or ArgoCD. They got the result of three months of platform work in ten minutes.

That is the value of an Internal Developer Platform. Not the technology. The time it gives back to every engineer who uses it.

Next: Capstone 5 — Production Readiness Challenge. Everything you have built will break. Capstone 5 teaches you how to find the breaks before your users do.

Videos & Guides

No external walkthroughs, video tutorials, or reference files attached to this blueprint yet.