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.
Domains & 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:
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 repoAfter 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.
## Scaffold a new Backstage application## This downloads the latest Backstage app template and sets up the projectnpx @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 dependenciesyarn install ## Verify the app startsyarn dev## Opens http://localhost:3000 (frontend) and http://localhost:7007 (backend)TipThe first
yarn devtakes 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.
## 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 repositoriesintegrations: 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 securelyproxy: '/prometheus/api': target: http://monitoring-kube-prometheus-prometheus.monitoring.svc:9090 ## TechDocs — docs-as-code, rendered from Markdown in each repotechdocs: 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 accountauth: providers: github: development: clientId: ${AUTH_GITHUB_CLIENT_ID} clientSecret: ${AUTH_GITHUB_CLIENT_SECRET} ## Catalog — where Backstage discovers servicescatalog: 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 pagekubernetes: 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 certRememberNever put secrets directly in
app-config.yaml. Use environment variables (${VARIABLE_NAME}) and a.envfile locally. In production, use Kubernetes Secrets or AWS Parameter Store.
Set environment variables
## Create a .env file for local development## Add .env to .gitignore — never commit this filecat > .env << 'EOF'GITHUB_TOKEN=ghp_your_personal_access_token_hereAUTH_GITHUB_CLIENT_ID=your_oauth_app_client_idAUTH_GITHUB_CLIENT_SECRET=your_oauth_app_client_secretK8S_CLUSTER_URL=https://your-eks-api-server.ap-south-1.eks.amazonaws.comK8S_SERVICE_ACCOUNT_TOKEN=eyJhbGciOiJSUzI1NiJ9...K8S_CA_DATA=LS0tLS1CRUdJTi...EOF ## Load env vars when starting the appexport $(cat .env | xargs) && yarn devPart 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.
## 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/v1alpha1kind: 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## catalog-info.yaml for the team that owns the service## Usually lives in a central 'org-catalog' repository apiVersion: backstage.io/v1alpha1kind: Groupmetadata: name: backend-team description: The team that builds and operates the swiggy-clone backendspec: 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-kumarapiVersion: backstage.io/v1alpha1kind: Resourcemetadata: 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.yamlspec: type: database owner: group:backend-team dependencyOf: - component:default/swiggy-clone-backendConfigure 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.
## Install the GitHub discovery provider plugincd packages/backendyarn 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:// packages/backend/src/index.ts// Add this import at the topimport { 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.yamlbuilder.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
## Start Backstage and verify the catalog is workingexport $(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 repositoryPart 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
## Install the frontend plugin (shows Kubernetes data on catalog pages)cd packages/appyarn add @backstage/plugin-kubernetes ## Install the backend plugin (fetches data from the cluster)cd packages/backendyarn add @backstage/plugin-kubernetes-backend// 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>);// 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.
## k8s/backstage-reader.yaml## Apply this to your EKS cluster from Capstone 2 apiVersion: v1kind: ServiceAccountmetadata: name: backstage-reader namespace: defaultapiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata: name: backstage-readerrules: ## 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/v1kind: ClusterRoleBindingmetadata: name: backstage-readerroleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: backstage-readersubjects: - kind: ServiceAccount name: backstage-reader namespace: default## Apply the RBAC configurationkubectl apply -f k8s/backstage-reader.yaml ## Extract the service account token for app-config.yaml## Kubernetes 1.24+ requires explicit token creationkubectl apply -f - << 'EOF'apiVersion: v1kind: Secretmetadata: name: backstage-reader-token namespace: default annotations: kubernetes.io/service-account.name: backstage-readertype: kubernetes.io/service-account-tokenEOF ## Get the token and CA dataK8S_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 fileWhat 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
## 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/v1beta3kind: Templatemetadata: 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 galleryspec: 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.
## templates/nodejs-microservice/skeleton/catalog-info.yaml## Pre-filled with the values from the form apiVersion: backstage.io/v1alpha1kind: Componentmetadata: 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 - backendspec: type: service lifecycle: experimental ## new services start as experimental owner: ${{ values.owner }}## templates/nodejs-microservice/skeleton/k8s/deployment.yaml## Follows the exact pattern from Capstone 1 apiVersion: apps/v1kind: Deploymentmetadata: name: ${{ values.name }} namespace: ${{ values.namespace }} labels: app: ${{ values.name }} app.kubernetes.io/managed-by: backstage-golden-pathspec: 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: 5apiVersion: v1kind: Servicemetadata: name: ${{ values.name }}-service namespace: ${{ values.namespace }}spec: selector: app: ${{ values.name }} ports: - name: http port: 4000 targetPort: 4000## templates/nodejs-microservice/skeleton/k8s/hpa.yaml apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: 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## 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/v1alpha1kind: Applicationmetadata: name: ${{ values.name }}-${{ values.environment }} namespace: argocd finalizers: - resources-finalizer.argocd.argoproj.iospec: 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## templates/nodejs-microservice/skeleton/Dockerfile## Multi-stage build — follows the Capstone 1 pattern FROM node:20-alpine AS builderWORKDIR /appCOPY 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 /appCOPY --from=builder /app/node_modules ./node_modulesCOPY --chown=appuser:appgroup . . USER appuserEXPOSE 4000CMD ["node", "src/index.js"]<!-- 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 ```bashnpm installnpm run devDeployment
This service is deployed automatically via ArgoCD. To deploy a new version:
- Push to main branch
- CI builds and pushes the Docker image
- Update the image tag in k8s/deployment.yaml
- ArgoCD detects the change and deploys within 3 minutes
Ownership
Owner: ${{ values.owner }}
### 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/v1alpha1kind: Locationmetadata: name: devops-network-templates description: Golden path templates for DevOps Networkspec: targets: - ./nodejs-microservice/template.yaml ## add more templates here as you build them## 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:
- Open
http://localhost:3000and sign in with GitHub - Click Create in the left sidebar
- See the Node.js Microservice template with a "Recommended" badge
- Click Choose on the template
- Fill in the form: Service Name =
notification-service, Description =Sends push notifications for order updates, Owner =backend-team, Namespace =notifications-staging, Replicas =2 - Click Review — see a summary of what will be created
- Click Create
- Watch the steps complete in real time: Fetch Template → Create GitHub Repository → Register in Catalog
- Click Open in Catalog — the notification-service page is live in Backstage
- Click GitHub Repository — the repository exists with all files populated
- Wait 3 minutes — ArgoCD detects the
argocd/application.yamland 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
## 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 declarationplugins: - techdocs-core ## Document navigation structurenav: - 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 featuresmarkdown_extensions: - admonition ## note/warning/tip blocks - pymdownx.details ## collapsible sections - pymdownx.highlight: anchor_linenums: true - pymdownx.superfences ## code blocks with line highlighting<!-- 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, andpromotional 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 Kafkatopic, processes them, and dispatches to FCM (Firebase Cloud Messaging) forAndroid 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
// 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, });}TipThe
localbuilder 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 theexternalbuilder. This makes the first page load instant.
Part 7 — Production Checklist
## ─── 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 createdcurl -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 repocurl -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 catalogcurl -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 clusterkubectl 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 verificationargocd app delete test-service-staging --cascadekubectl 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:
## Check the Backstage backend logs for GitHub errorsyarn 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 filenpx 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:
## 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 stepsnpx @backstage/create-app -- --skip-install \ --template-path ./templates/nodejs-microservice/skeleton \ --values '{"name":"test-service","namespace":"default","replicas":2}'Kubernetes plugin showing no data:
## Step 1: Verify the service account can read podskubectl 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=backendkubectl 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 expiredkubectl delete secret backstage-reader-token -n defaultkubectl apply -f k8s/backstage-reader.yaml## Then update K8S_SERVICE_ACCOUNT_TOKEN in .envWhat You Have Built
Four capstones. Four layers of a complete platform engineering stack.
✅ 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 currentHere 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.