DEV Community

DevOps Start
DevOps Start

Posted on • Originally published at devopsstart.com

How to Build a Developer Control Plane with Backstage

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.x or v20.x. This guide uses v20.11.1. You can use a tool like nvm to manage Node versions.
  • Yarn: Backstage uses Yarn v1 for 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now, start the application. The backend and frontend run as separate processes.

yarn dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This file contains several key fields:

  • apiVersion and kind: Define the entity type. Component is the most common kind, representing a piece of software.
  • metadata.name: A unique identifier for the component within Backstage.
  • metadata.annotations: Provides external identifiers. The github.com/project-slug annotation is crucial for plugins like GitHub Actions to find the correct repository.
  • spec.type: The type of component, for example, service, website, or library.
  • spec.lifecycle: The current maturity stage, such as experimental, production, or deprecated.
  • spec.owner: Specifies who owns this component. This is often a team or user group. For now, we'll use the default guest user.

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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" ]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Render the template files with your inputs.
  2. Create a new repository in your GitHub account.
  3. Push the rendered files to the new repository.
  4. 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'
Enter fullscreen mode Exit fullscreen mode

Add Documentation to a Service

Let's add documentation to the sample-service we created earlier. In that service's repository, do the following:

  1. Add the TechDocs annotation to catalog-info.yaml. This tells Backstage where to find the documentation source. The dir:. 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
Enter fullscreen mode Exit fullscreen mode
  1. Create a /docs directory and add an index.md file 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
Enter fullscreen mode Exit fullscreen mode

GitHub Auth Fails

Symptom: The GitHub Actions plugin shows an error, or the Scaffolder fails at the "publish" step with an authentication error.
Fix:

  1. Verify the Token: Double-check that you've exported the GITHUB_TOKEN environment variable in the same terminal session where you run yarn dev.
  2. Check Scopes: Ensure your GitHub Personal Access Token (classic) has the repo scope. For creating new repositories via the Scaffolder, it may also need the workflow scope.
  3. 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:

  1. Check the URL: Make sure the target URL in app-config.yaml points directly to the raw catalog-info.yaml file on your Git provider.
  2. Validate YAML: Use a YAML linter to check for syntax errors in your catalog-info.yaml. Indentation errors are common.
  3. Check Backend Logs: The Backstage backend logs (from the yarn dev command) will often show detailed error messages about why a location failed to be ingested. Look for lines containing Catalog-Processor or error.

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)