Looking to reduce cognitive load for your engineering teams? This tutorial, originally published on devopsstart.com, walks you through building a developer control plane using Backstage.
An Internal Developer Platform (IDP) is a centralized control plane that gives your development teams a paved road for building, deploying and managing software. Instead of managing dozens of different tools and CLIs, developers get a single, curated interface for everything from creating a new microservice to checking its CI/CD status or viewing its documentation. This tutorial shows you how to build a foundational IDP using Backstage.io, the open-source framework for building developer portals created by Spotify and now a CNCF graduated project.
You will learn to set up a Backstage application, populate its software catalog, integrate GitHub Actions to view pipeline runs and create a software template that lets developers scaffold new services in minutes.
What is an Internal Developer Platform?
An Internal Developer Platform (IDP) is a layer built on top of your existing DevOps toolchain that exposes your infrastructure and tooling through a simplified, self-service interface. It codifies best practices and organizational standards into "golden paths", enabling developers to create and manage applications without needing deep expertise in Kubernetes, Terraform or complex CI/CD configurations.
Backstage is the leading open-source project for building IDPs. It provides a pluggable frontend and backend that act as a central hub. It's not a replacement for tools like Jenkins, Argo CD or Grafana. Instead, it integrates with them, presenting their information and actions within a unified system. This approach turns a complex, distributed toolchain into a cohesive and discoverable platform. An IDP reduces cognitive load on developers by abstracting away the underlying complexity of cloud-native infrastructure, letting them focus on writing code instead of fighting with tooling.
Prerequisites
To follow this tutorial, you need a few tools installed on your local machine.
-
Node.js: Backstage is a TypeScript/JavaScript application. You need Node.js
v18.xorv20.x. This guide usesv20.11.1. You can use a tool likenvmto manage Node versions. -
Yarn: Backstage uses Yarn
v1for package management. After installing Node.js, you can install it globally:
npm install -g yarn
shell
* **Docker:** The Backstage backend runs in a Docker container during local development to connect to a PostgreSQL database. Ensure Docker Desktop or an equivalent is installed and running.
* **`npx`:** This command-line tool is included with `npm` (which comes with Node.js) and is used to run the Backstage app creation script without a global installation.
* **A GitHub Account and Personal Access Token (PAT):** Backstage integrates with GitHub to discover components for the software catalog and display CI/CD information. You need a GitHub account and a PAT with the `repo` scope to allow Backstage to read repository information and workflow runs. You can create a token in your GitHub settings under `Developer settings > Personal access tokens > Tokens (classic)`.
## Step 1: Scaffold a New Backstage App
The fastest way to get started is with the Backstage CLI's `create-app` command. This script scaffolds a complete monorepo with a frontend, a backend and all the necessary configuration to run locally.
First, run the interactive creator using `npx`:
```bash
npx @backstage/create-app@latest
The script will prompt you for an application name. Let's call it dev-control-plane:
? Enter a name for the app [required] dev-control-plane
This process takes 5-10 minutes depending on your network speed, as it clones the template, installs all npm dependencies and sets up the basic structure.
Once it's finished, navigate into the new directory:
cd dev-control-plane
The directory structure looks like this:
.
├── app-config.yaml # Main configuration file for your app
├── catalog-info.yaml # Registers this app in its own catalog
├── lerna.json
├── package.json # Root package.json for the monorepo
├── packages/
│ ├── app/ # The frontend application (React)
│ └── backend/ # The backend application (Node.js/Express)
└── yarn.lock
Now, start the application. The backend and frontend run as separate processes.
yarn dev
This command starts the backend on port 7007 and the frontend on port 3000. After a minute or two of compilation, your web browser should automatically open to http://localhost:3000.
You now have a running, albeit empty, Backstage application. The initial view shows an example catalog with a few components. The next step is to clear these examples and populate the catalog with your own services.
Step 2: Configure the Software Catalog
The Software Catalog is the heart of Backstage. It's a centralized system for tracking ownership and metadata for all your software, including microservices, libraries, websites and machine learning models. Backstage discovers these components by ingesting catalog-info.yaml files from your Git repositories.
For this example, you will need a sample GitHub repository containing a catalog-info.yaml file. You can create a new public repository named sample-service or use one of your existing projects. Throughout this guide, replace your-org with your actual GitHub username or organization name.
Create a catalog-info.yaml file in the root of that repository:
# In your-org/sample-service/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: sample-service
description: A sample service for the Backstage catalog.
annotations:
github.com/project-slug: your-org/sample-service
spec:
type: service
lifecycle: experimental
owner: user:guest
system: examples
This file contains several key fields:
-
apiVersionandkind: Define the entity type.Componentis the most common kind, representing a piece of software. -
metadata.name: A unique identifier for the component within Backstage. -
metadata.annotations: Provides external identifiers. Thegithub.com/project-slugannotation is crucial for plugins like GitHub Actions to find the correct repository. -
spec.type: The type of component, for example,service,website, orlibrary. -
spec.lifecycle: The current maturity stage, such asexperimental,production, ordeprecated. -
spec.owner: Specifies who owns this component. This is often a team or user group. For now, we'll use the defaultguestuser.
Now, tell your Backstage application to find this file. Open app-config.yaml in the root of your dev-control-plane project and find the catalog.locations section. Replace the example rules with a single entry pointing to your repository.
# in app-config.yaml
catalog:
import:
entityFilename: catalog-info.yaml
pullRequestBranchName: backstage-integration
rules:
- allow: [Component, API, Resource, Group, User, System, Domain, Template, Location]
locations:
# Remove the example locations and add this one:
- type: url
target: https://github.com/your-org/sample-service/blob/main/catalog-info.yaml
Restart your yarn dev process for the changes to take effect. When Backstage starts up, it will fetch this YAML file, process it and add the sample-service component to the catalog. You can now see it on the main page. This declarative, "as-code" approach to catalog management is powerful because the catalog stays in sync with your source code, and ownership information is version-controlled right alongside the service itself.
Step 3: Integrate a CI/CD Plugin (GitHub Actions)
Seeing a list of services is useful, but the real power of an IDP comes from integrating operational data. Let's add the GitHub Actions plugin to display CI/CD status directly on the component page in Backstage.
Add GitHub Integration Configuration
First, configure Backstage to authenticate with the GitHub API. This requires the Personal Access Token (PAT) you created earlier.
Open app-config.yaml and add the following integrations section. You may also need to add the top-level github key if it's not present.
# in app-config.yaml
integrations:
github:
- host: github.com
token: ${GITHUB_TOKEN}
# This key may already exist. If so, just ensure the token is set.
github:
token: ${GITHUB_TOKEN}
We're using an environment variable GITHUB_TOKEN to avoid committing secrets to version control. When you run yarn dev, you'll need to export this variable.
export GITHUB_TOKEN="your_classic_github_pat_here"
yarn dev
Production Gotcha: For a production deployment, you would use a secret management system like HashiCorp Vault or AWS Secrets Manager to inject this token, not an environment variable on your local machine. Proper secret handling is critical. Tools like the GitHub Actions Security scanner can help you detect accidentally committed secrets. For a deep dive, check out our guide on how to stop secret leaks in CI/CD.
Install and Configure the Plugin
Next, install the GitHub Actions plugin package in your frontend app.
cd packages/app
yarn add @backstage/plugin-github-actions
Now, you need to add the plugin's UI component to the entity page, which displays detailed information about a single component. Open the file packages/app/src/components/catalog/EntityPage.tsx.
Import the plugin components, then modify the cicdContent constant to conditionally render the GitHub Actions view.
// in packages/app/src/components/catalog/EntityPage.tsx
// ... other imports
import {
EntityGithubActionsContent,
isGithubActionsAvailable,
} from '@backstage/plugin-github-actions';
import { Grid, Card, CardContent } from '@material-ui/core'; // Ensure Grid is imported
import { EntitySwitch } from '@backstage/plugin-catalog'; // Ensure EntitySwitch is imported
// ...
const cicdContent = (
<Grid container spacing={3} alignItems="stretch">
<EntitySwitch>
<EntitySwitch.Case if={isGithubActionsAvailable}>
<Grid item sm={12}>
<EntityGithubActionsContent />
</Grid>
</EntitySwitch.Case>
<EntitySwitch.Default>
<Grid item>
<Card>
<CardContent>
No CI/CD provider available for this entity.
</CardContent>
</Card>
</Grid>
</EntitySwitch.Default>
</EntitySwitch>
</Grid>
);
This code uses EntitySwitch to conditionally render the GitHub Actions content only if the component has the necessary github.com/project-slug annotation.
After saving the file, the dev server should automatically reload. Navigate to your sample-service component in the catalog. You should now see a "CI/CD" tab, and inside it, a view of the recent GitHub Actions workflow runs for that repository. A developer can now see if their last commit passed its tests without leaving Backstage.
Step 4: Create a Software Template with the Scaffolder
One of the most powerful features of Backstage is the Software Scaffolder. It allows you to create templates for new projects, enforcing best practices and setting up everything a developer needs automatically.
Let's create a template that scaffolds a new Node.js "hello world" service, complete with a Dockerfile, a catalog-info.yaml file and registration in a new GitHub repository.
Create the Template Definition
First, create a new directory for your templates at the root of your dev-control-plane project.
mkdir -p templates/nodejs-service
Inside this directory, create a template.yaml file. This file defines the template's metadata and the input parameters it requires from the user.
# in templates/nodejs-service/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: nodejs-service-template
title: Node.js Service
description: Creates a simple Node.js service with Docker.
spec:
owner: user:guest
type: service
# These parameters are used to gather user-provided information.
parameters:
- title: Component Details
required:
- component_id
- owner
properties:
component_id:
title: Name
type: string
description: Unique name of the component
ui:field: EntityNamePicker
description:
title: Description
type: string
description: A description for this component
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
- title: Repository Location
required:
- repoUrl
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
# These steps are executed in order.
steps:
- id: fetch-base
name: Fetch Base
action: fetch:template
input:
url: ./content
values:
component_id: ${{ parameters.component_id }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
repoUrl: ${{ parameters.repoUrl }}
- id: publish
name: Publish
action: publish:github
input:
allowedHosts: ['github.com']
description: This is ${{ parameters.description }}
repoUrl: ${{ parameters.repoUrl }}
- id: register
name: Register
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: '/catalog-info.yaml'
# The output of a successful template run.
output:
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Open in catalog
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}
Create the Template Content
Next, create a content subdirectory within templates/nodejs-service. This will hold the skeleton files for our new service.
mkdir templates/nodejs-service/content
Inside templates/nodejs-service/content, create the following files. These are Handlebars templates, where {{ ... }} expressions will be replaced by user-provided values.
catalog-info.yaml.hbs:
# templates/nodejs-service/content/catalog-info.yaml.hbs
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.component_id | dump }}
description: ${{ values.description | dump }}
annotations:
github.com/project-slug: ${{ values.repoUrl | parseRepoUrl | pick('owner') }}/${{ values.repoUrl | parseRepoUrl | pick('repo') }}
backstage.io/techdocs-ref: dir:.
spec:
type: service
lifecycle: experimental
owner: ${{ values.owner | dump }}
index.js.hbs:
// templates/nodejs-service/content/index.js.hbs
const express = require('express');
const app = express();
const port = 8080;
app.get('/', (req, res) => {
res.send('Hello from ${{ values.component_id }}!');
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
package.json.hbs:
// templates/nodejs-service/content/package.json.hbs
{
"name": "${{ values.component_id }}",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"express": "^4.18.2"
}
}
Dockerfile.hbs:
For an in-depth guide on creating efficient Dockerfiles, see our article on Docker multi-stage builds.
# templates/nodejs-service/content/Dockerfile.hbs
FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 8080
CMD [ "node", "index.js" ]
Register the Template
Finally, add the template to your app-config.yaml so Backstage can find it.
# in app-config.yaml
catalog:
locations:
# ... your other locations
- type: file
target: ../../templates/nodejs-service/template.yaml
rules:
- allow: [Template]
Restart your yarn dev server. Go to http://localhost:3000/create. You should now see your "Node.js Service" template. Clicking "Choose" will take you to a form where you can enter the component name, owner and desired GitHub repository location.
When you click "Create", the Scaffolder will:
- Render the template files with your inputs.
- Create a new repository in your GitHub account.
- Push the rendered files to the new repository.
- Register the new component in the Backstage catalog.
You've now automated the creation of new services, ensuring they all start from a standardized, compliant baseline.
Step 5: Integrate Documentation with TechDocs
The final piece of our control plane is centralized documentation. Backstage's TechDocs feature renders Markdown documentation stored alongside your code directly within the Backstage UI.
Configure TechDocs
TechDocs requires a backend plugin and a location to store the generated documentation site. For local development, it can use a local generator and storage directory. This configuration is usually present by default in new Backstage applications.
Open app-config.yaml and ensure the techdocs section is configured for local development.
# in app-config.yaml
techdocs:
builder: 'local' # Can be 'local' or 'external'
generator:
runIn: 'docker' # 'docker' or 'local'
publisher:
type: 'local' # 'local' or 'googleGcs' or 'awsS3' or 'azureBlobStorage'
Add Documentation to a Service
Let's add documentation to the sample-service we created earlier. In that service's repository, do the following:
-
Add the TechDocs annotation to
catalog-info.yaml. This tells Backstage where to find the documentation source. Thedir:.value means "look in the current directory".
# in your-org/sample-service/catalog-info.yaml metadata: # ... other metadata annotations: # ... other annotations backstage.io/techdocs-ref: dir:.
yaml
2. **Create an `mkdocs.yml` file** in the root of the repository. This is the configuration file for MkDocs, the static site generator TechDocs uses.
```yaml
# in your-org/sample-service/mkdocs.yml
site_name: 'Sample Service Documentation'
nav:
- Home: index.md
-
Create a
/docsdirectory and add anindex.mdfile inside it.
mkdir docs echo "# Sample Service\n\nThis is the main documentation page." > docs/index.md
yaml
Commit and push these changes to your repository.
Navigate to your `sample-service` component in Backstage and click the "Docs" tab. The first time, you may need to wait a few minutes for Backstage to generate the documentation site. Once it's ready, you'll see your rendered Markdown file. You now have a single place where any developer can find up-to-date, version-controlled documentation for any service.
## Troubleshooting Common Issues
When setting up Backstage for the first time, you might run into a few common problems.
### CORS Errors
**Symptom:** The Backstage frontend fails to load data from the backend, and you see Cross-Origin Resource Sharing (CORS) errors in your browser's developer console.
**Fix:** Ensure your `app-config.yaml` has the correct `backend.cors.origin` setting for local development:
```yaml
# in app-config.yaml
backend:
# ...
cors:
origin: http://localhost:3000
methods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
credentials: true
GitHub Auth Fails
Symptom: The GitHub Actions plugin shows an error, or the Scaffolder fails at the "publish" step with an authentication error.
Fix:
-
Verify the Token: Double-check that you've exported the
GITHUB_TOKENenvironment variable in the same terminal session where you runyarn dev. -
Check Scopes: Ensure your GitHub Personal Access Token (classic) has the
reposcope. For creating new repositories via the Scaffolder, it may also need theworkflowscope. - Check Organization Settings: If publishing to a GitHub organization, it may have settings that restrict PAT access or require third-party application approval.
Catalog Import Fails
Symptom: A component you added to catalog.locations doesn't appear in the UI.
Fix:
-
Check the URL: Make sure the
targetURL inapp-config.yamlpoints directly to the rawcatalog-info.yamlfile on your Git provider. -
Validate YAML: Use a YAML linter to check for syntax errors in your
catalog-info.yaml. Indentation errors are common. -
Check Backend Logs: The Backstage backend logs (from the
yarn devcommand) will often show detailed error messages about why a location failed to be ingested. Look for lines containingCatalog-Processororerror.
You have now built the foundation of a powerful Internal Developer Platform. You've created a central place for service discovery, integrated real-time operational data, automated new service creation and centralized documentation. This is the core of a "developer control plane" that can significantly improve your team's productivity and standardize your engineering practices. From here, you can explore hundreds of other plugins for tools like Argo CD, Kubernetes and Grafana to build out a truly comprehensive platform.
Top comments (0)