Build an Internal Developer Portal with Backstage
Deploy Backstage on Kubernetes with a software catalog, a golden path template, TechDocs, GitHub Actions status, and links to your dashboards.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds an Internal Developer Portal with Backstage and runs it on Kubernetes with PostgreSQL. You create your own Backstage app, fill its software catalog from GitHub, add a golden path template that scaffolds a new service, publish documentation with TechDocs, and show CI status and dashboard links on each service page. You finish with a running portal, a template repository, and a short write-up of how long a new service takes to create, all of which you can show in a portfolio.
This is the first of three connected platform projects, and it builds the front door that developers use. Do it first. The next project, Build a Mini Internal Developer Platform, builds platform automation behind a portal like this one, and The Platform Engineering Challenge is the capstone that ties them together. You should be comfortable with Docker, Kubernetes deployments, and GitHub Actions before you start.
What you build:
- A Backstage app with its own catalog of services, teams, and a system
- GitHub discovery, so any repository with a
catalog-info.yamlappears automatically - A Node.js golden path template that creates a GitHub repository and registers it
- TechDocs rendered from Markdown that lives next to the code
- A production container image and a Kubernetes deployment backed by PostgreSQL
golden path template] BS --> DOCS[TechDocs] CAT -->|discovers| GH[GitHub repos
catalog-info.yaml] TPL -->|creates repo| GH BS --> PG[(PostgreSQL)] CAT -->|links to| GRAF[Grafana dashboards] classDef portal fill:#F97316,color:#fff classDef feat fill:#8B5CF6,color:#fff classDef ext fill:#475569,color:#fff classDef data fill:#10B981,color:#fff class BS portal class CAT,TPL,DOCS feat class GH,GRAF ext class PG data
The problem this solves
As an engineering team grows past a dozen people, two things go wrong. Nobody can quickly tell what services exist, who owns them, or where their documentation and dashboards are. And creating a new service means copying an old one, rebuilding CI from memory, and asking three people how things are done.
Backstage gives every service a page with its owner, documentation, CI status, and links. A software template creates a new service with the repository, pipeline, docs folder, and catalog entry already in place. Treat the speed-up as something to measure: time how long a new service takes by hand and how long the template takes, then record both.
TipBackstage is a framework, not a product you install. You own a TypeScript monorepo, add plugins, and deploy it like any web application.
Milestone 1: Create the Backstage App and Run It Locally
This milestone creates your own Backstage app and confirms it runs. Starting locally gives you a fast feedback loop before anything goes near a cluster.
You need a current Node.js LTS release (Backstage dropped older versions, so check the supported range in the Backstage docs), Git, and Docker. Backstage uses Yarn, which Node manages through Corepack.
node --versioncorepack enable npx @backstage/create-app@latest# When asked, enter the app name: devops-network-portal cd devops-network-portalyarn startOpen http://localhost:3000. You should see the Backstage UI with its example catalog. The first start takes a few minutes because it builds the frontend and backend.
Common MistakeInstalling a global Yarn and fighting version errors. The generated app pins its own Yarn version, and
corepack enablemakes your shell use it.
Milestone 2: Build the Software Catalog
This milestone fills the catalog with real data. Every service, team, and system is an entity described in a catalog-info.yaml file, and Backstage reads those files to build the catalog and its dependency graph.
First, create the teams and system that services will point to. A service whose owner does not exist shows as an orphan, so define the owner first. Create catalog/org.yaml in the portal repository:
apiVersion: backstage.io/v1alpha1kind: Groupmetadata: name: platform-team description: Owns the platform and the portalspec: type: team children: []---apiVersion: backstage.io/v1alpha1kind: Systemmetadata: name: order-management description: Everything involved in taking and fulfilling ordersspec: owner: group:platform-teamThen add a catalog-info.yaml to the root of two service repositories. This one is for order-service; make a similar one for payment-service so the dependency below resolves.
apiVersion: backstage.io/v1alpha1kind: Componentmetadata: name: order-service description: Handles order creation and tracking annotations: github.com/project-slug: YOUR_ORG/order-service backstage.io/techdocs-ref: dir:. tags: - nodejs - orders links: - url: http://grafana.example.internal/d/order-service title: Grafana dashboard icon: dashboardspec: type: service lifecycle: production owner: group:platform-team system: order-management dependsOn: - component:payment-serviceInstall the GitHub catalog module so Backstage can discover these files:
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-githubIn packages/backend/src/index.ts, add:
backend.add(import('@backstage/plugin-catalog-backend-module-github'));In app-config.yaml, add the provider and the local locations. The token is read from an environment variable and never written into the file.
integrations: github: - host: github.com token: ${GITHUB_TOKEN} catalog: providers: github: main: organization: 'YOUR_GITHUB_ORG' catalogPath: '/catalog-info.yaml' filters: branch: 'main' repository: '.*' schedule: frequency: { minutes: 30 } timeout: { minutes: 3 } locations: - type: file target: ../../catalog/org.yaml rules: - allow: [Group, System, User]export GITHUB_TOKEN=<your token>yarn startFor this lab a classic personal access token with the repo scope is enough. In a real organisation use a GitHub App instead, so access is scoped and not tied to one person.
SecurityNever commit the token. If it appears in a repository, revoke it immediately and create a new one.
Common MistakeWondering why services never appear. Discovery needs the module installed, a valid token, a repository on the
mainbranch, and a file named exactlycatalog-info.yamlat the root. Check the backend logs for the catalog provider's errors.
Milestone 3: Create a Golden Path Template
This milestone builds a software template, a form that creates a ready-to-run service. A golden path is the recommended way to do something, made easy enough that developers choose it. The template fetches skeleton files, creates the repository, and registers the new service in the catalog.
Create templates/nodejs-microservice/template.yaml:
apiVersion: scaffolder.backstage.io/v1beta3kind: Templatemetadata: name: nodejs-microservice title: Node.js Microservice description: Node.js service with a Dockerfile, CI pipeline, Kubernetes manifests, and docs tags: - nodejs - recommendedspec: owner: group:platform-team type: service parameters: - title: Service details required: - name - description - owner properties: name: title: Service name type: string pattern: '^[a-z][a-z0-9-]*$' description: title: Description type: string owner: title: Owner team type: string ui:field: OwnerPicker ui:options: catalogFilter: kind: Group - title: Repository required: - repoUrl properties: repoUrl: title: Repository location type: string ui:field: RepoUrlPicker ui:options: allowedHosts: - github.com steps: - id: fetch-base name: Fetch skeleton action: fetch:template input: url: ./skeleton values: name: ${{ parameters.name }} description: ${{ parameters.description }} owner: ${{ parameters.owner }} - id: publish name: Publish to GitHub action: publish:github input: repoUrl: ${{ parameters.repoUrl }} description: ${{ parameters.description }} defaultBranch: main repoVisibility: private - id: register name: Register in catalog action: catalog:register input: repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} catalogInfoPath: '/catalog-info.yaml' output: links: - title: Repository url: ${{ steps['publish'].output.remoteUrl }} - title: Open in catalog icon: catalog entityRef: ${{ steps['register'].output.entityRef }}Create the skeleton in templates/nodejs-microservice/skeleton/:
skeleton/ catalog-info.yaml Dockerfile package.json src/index.js k8s/deployment.yaml k8s/service.yaml .github/workflows/ci.yml mkdocs.yml docs/index.mdThe skeleton's catalog-info.yaml is what registers each new service, so write it with template values:
apiVersion: backstage.io/v1alpha1kind: Componentmetadata: name: ${{ values.name }} description: ${{ values.description }} annotations: github.com/project-slug: ${{ values.destination.owner + "/" + values.destination.repo }} backstage.io/techdocs-ref: dir:.spec: type: service lifecycle: experimental owner: ${{ values.owner }}Tell Backstage where the template lives by adding it to catalog.locations in app-config.yaml:
- type: file target: ../../templates/nodejs-microservice/template.yaml rules: - allow: [Template]Common MistakeCopying a GitHub Actions workflow into the skeleton unchanged. Backstage templates also use the
${{ }}syntax, so it tries to fill in${{ github.sha }}and fails. Wrap those parts in{% raw %}and{% endraw %}so the template leaves them alone.
Milestone 4: Enable TechDocs
This milestone publishes documentation from Markdown files in each repository. Docs live next to the code, so a pull request that changes behaviour can change the docs in the same review.
In a service repository, add mkdocs.yml and a docs/index.md:
site_name: Order Servicedocs_dir: docsnav: - Home: index.mdplugins: - techdocs-coreWrite docs/index.md with plain Markdown: what the service does, how to run it locally, how it deploys, and an on-call section listing the first three things to check when it pages.
The portal builds those docs when someone opens the Docs tab. In app-config.yaml, use the local builder for the lab:
techdocs: builder: 'local' generator: runIn: 'local' publisher: type: 'local'The local builder needs Python and mkdocs-techdocs-core installed on the machine that runs the backend. For local development:
pip install mkdocs-techdocs-coreFor production, the usual approach is the opposite: set builder: 'external', build the docs in each repository's CI with techdocs-cli, and publish them to an object store such as S3. That keeps the portal image small and the docs build out of the request path.
Milestone 5: Show CI Status and Monitoring Links
This milestone makes each service page useful during an incident. Developers should see the latest pipeline runs and reach the right dashboard in one click, without asking anyone.
Install the GitHub Actions plugin and add its tab to the service page by following the plugin's README, since the frontend wiring depends on your Backstage version:
yarn --cwd packages/app add @backstage-community/plugin-github-actionsThe github.com/project-slug annotation from Milestone 2 tells the plugin which repository to read. Dashboard and runbook links come from the links list in catalog-info.yaml, so point them at your Grafana dashboards, such as the SLO dashboard from the monitoring projects.
TipA catalog link is the cheapest integration there is. Add real plugins such as Kubernetes or PagerDuty only after the links prove which information developers actually look for.
Milestone 6: Build the Image and Deploy to Kubernetes
This milestone runs the portal as a container in a cluster. The default Backstage setup uses an in-memory SQLite database that loses everything on restart, so production needs PostgreSQL.
Add production settings in app-config.production.yaml. Use url locations so the catalog files come from Git, because the container does not contain your local folders. Replace the host with the URL you will use to reach the portal.
app: baseUrl: http://localhost:7007 backend: baseUrl: http://localhost:7007 listen: port: 7007 database: client: pg connection: host: ${POSTGRES_HOST} port: ${POSTGRES_PORT} user: ${POSTGRES_USER} password: ${POSTGRES_PASSWORD} auth: providers: guest: dangerouslyAllowOutsideDevelopment: true catalog: locations: - type: url target: https://github.com/YOUR_ORG/devops-network-portal/blob/main/catalog/org.yaml rules: - allow: [Group, System, User] - type: url target: https://github.com/YOUR_ORG/devops-network-portal/blob/main/templates/nodejs-microservice/template.yaml rules: - allow: [Template]SecurityGuest login lets anyone who reaches the portal in. It is acceptable for a private lab you reach through a port-forward, and never for a shared or public deployment. Before you expose the portal, set up a real sign-in provider such as GitHub OAuth by following the Backstage authentication guide.
Add Python and mkdocs-techdocs-core to the final stage of packages/backend/Dockerfile (the Backstage deployment docs show the exact lines), then build the image:
yarn install --immutableyarn tscyarn build:backend docker image build . -f packages/backend/Dockerfile --tag backstage:1.0.0 # Local cluster (kind):kind load docker-image backstage:1.0.0# Or push to your registry:# docker tag backstage:1.0.0 YOUR_REGISTRY/backstage:1.0.0 && docker push YOUR_REGISTRY/backstage:1.0.0Create the namespace, secrets, PostgreSQL, and Backstage:
kubectl create namespace backstage kubectl create secret generic backstage-secrets -n backstage \ --from-literal=GITHUB_TOKEN=<your token> \ --from-literal=POSTGRES_USER=backstage \ --from-literal=POSTGRES_PASSWORD=<choose a password> kubectl apply -n backstage -f - <<EOFapiVersion: apps/v1kind: StatefulSetmetadata: name: postgresspec: serviceName: postgres replicas: 1 selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:16 ports: - containerPort: 5432 env: - name: POSTGRES_USER valueFrom: secretKeyRef: name: backstage-secrets key: POSTGRES_USER - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: backstage-secrets key: POSTGRES_PASSWORD - name: PGDATA value: /var/lib/postgresql/data/pgdata volumeMounts: - name: data mountPath: /var/lib/postgresql/data volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 5Gi---apiVersion: v1kind: Servicemetadata: name: postgresspec: selector: app: postgres ports: - port: 5432---apiVersion: apps/v1kind: Deploymentmetadata: name: backstagespec: replicas: 1 selector: matchLabels: app: backstage template: metadata: labels: app: backstage spec: containers: - name: backstage image: backstage:1.0.0 imagePullPolicy: IfNotPresent ports: - containerPort: 7007 envFrom: - secretRef: name: backstage-secrets env: - name: POSTGRES_HOST value: postgres - name: POSTGRES_PORT value: "5432" resources: requests: cpu: 250m memory: 512Mi limits: cpu: "1" memory: 1Gi---apiVersion: v1kind: Servicemetadata: name: backstagespec: selector: app: backstage ports: - port: 7007EOF kubectl get pods -n backstagekubectl port-forward -n backstage svc/backstage 7007:7007Open http://localhost:7007. The container serves both the frontend and the backend on one port, which is why both base URLs match.
Common MistakeLeaving
baseUrlatlocalhost:3000in the production config. The page loads but every API call goes to the wrong address. Bothapp.baseUrlandbackend.baseUrlmust match the address you use in the browser.
Validation & Testing
Work through these checks, and note what you see.
# The portal and database are runningkubectl get pods -n backstage# Expected: backstage and postgres pods Running kubectl logs -n backstage deploy/backstage --tail=50# Expected: no repeated catalog or database errorsIn the browser at http://localhost:7007:
- Catalog: open Catalog and filter by Kind
Component. Expected:order-serviceandpayment-serviceappear with the ownerplatform-team. - Dependencies: open
order-serviceand view its relations. Expected:payment-serviceis shown as a dependency. - Template: open Create and choose Node.js Microservice. Fill in the form and run it. Expected: a new private GitHub repository exists, and the new service appears in the catalog.
- Docs: open the Docs tab of a service. Expected: the rendered Markdown from its repository.
- CI: open the CI tab. Expected: recent GitHub Actions runs for that repository.
- Auto-discovery: add a
catalog-info.yamlto a new repository onmainand wait for the 30-minute scan. Expected: the new service appears without any portal change. - Persistence: run
kubectl rollout restart deploy/backstage -n backstage. Expected: the catalog is intact afterwards, which proves the data lives in PostgreSQL.
For your portfolio, add a README with screenshots of the catalog, the dependency graph, and the template form, plus two timings: how long creating a service by hand takes and how long the template takes. State the numbers you measured, not a promise of what the portal saves.
Before calling this production-ready, list what remains: a real sign-in provider, a GitHub App instead of a personal token, an ingress with TLS, TechDocs published to object storage, and more than one replica.