How to Set Up Backstage as an Internal Developer Portal (2026)

Oct 20, 2026 · 14 min · Ajay Kumar

This guide is for platform and DevOps engineers who want a working Backstage developer portal, not a demo that falls over the first time someone logs in. By the end you will have Backstage running on PostgreSQL with GitHub sign-in, your own services in the software catalog, a software template that creates a repository, TechDocs rendering Markdown from that repository, and a container image deployed to Kubernetes with the official Helm chart. Everything here was checked against Backstage v1.55.3 (released 29 September 2026, @backstage/create-app 0.9.2) and the Backstage Helm chart 2.10.2. You need a Unix-like machine (Linux, macOS or WSL) with Node.js 22 or 24, Docker, Git, at least 6 GB of RAM and 20 GB of disk, plus a GitHub account and a Kubernetes cluster for the last step. If you are still deciding whether you need a portal or a platform team at all, read the companion explainer, Platform Engineering vs DevOps vs SRE in 2026, first.

From empty directory to a portal on Kubernetes 1. Scaffold the app npx @backstage/create-app@latest v1.55.3, Node 22 or 24 2-3. Postgres + sign-in pg client, GitHub OAuth app, userIdMatchingUserEntityAnnotation 4. Software catalog catalog-info.yaml per repo, locations + rules in app-config 5. Software templates fetch:template, publish:github, catalog:register 6. TechDocs mkdocs.yml + techdocs-ref, build in CI, publish to S3/GCS 7. Ship to Kubernetes yarn build:backend, docker build Helm chart 2.10.2, health probes Guest auth and in-memory SQLite are development defaults: replace both before step 7.
The order this guide follows. Versions are Backstage v1.55.3 and the backstage/charts Helm chart 2.10.2, current as of 20 October 2026.

Step 1: Create the Backstage app with create-app

Backstage is not something you install as a binary. You generate a monorepo that you own, then add plugins to it as code. The getting started guide recommends Node 22 or 24 and Yarn 4.4.1 via Corepack, and the generated package.json pins "node": "22 || 24". Node 20 will not work: since v1.54, create-app checks the Node.js LTS version before scaffolding (v1.54 release notes).

# Node 24 is Active LTS; Backstage supports exactly two adjacent even releases.
nvm install 24 && nvm use 24
corepack enable

# Generates ./my-backstage-app (you will be prompted for the name).
npx @backstage/create-app@latest

cd my-backstage-app
cat backstage.json      # {"version": "1.55.3"} for create-app 0.9.2
yarn start              # frontend on :3000, backend on :7007

When the log prints Rspack compiled successfully, open http://localhost:3000. You will see the catalog with demo entities, signed in as a shared guest user.

Two architectural facts matter before you write any code. First, the backend uses the new backend system: packages/backend/src/index.ts is a list of backend.add(import('...')) lines, and adding a plugin is usually one package plus one line. Second, since v1.49.0 (March 2026) new apps use the new frontend system by default, so packages/app/src/App.tsx is a short createApp({ features: [...] }) call and most UI wiring is config under app.extensions. A lot of blog posts and plugin READMEs still show the old <Route>-based App.tsx. If a snippet references FlatRoutes or apis.ts, it is for the old system.

New apps also ship @backstage/plugin-mcp-actions-backend, which exposes catalog and scaffolder actions to MCP clients at /api/mcp-actions/v1; the old SSE transport was removed in v1.53.0. If agents will create services through your portal, that endpoint is how they will do it; since v1.54.0 its connections, tool discovery and tool calls emit audit events.

Step 2: Switch the database to PostgreSQL

The default config uses in-memory SQLite, so every restart wipes the catalog. The generated backend already depends on pg, so you only need a database and config. For local work, run PostgreSQL 18, the version the database guide uses:

docker run -d --name backstage-postgres --restart=always \
  -p 5432:5432 -e POSTGRES_PASSWORD=change-me postgres:18-trixie

Create app-config.local.yaml in the repo root (it is git-ignored and excluded from the Docker build context) and point the backend at it:

# app-config.local.yaml: overrides app-config.yaml when running `yarn start`
backend:
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: ${POSTGRES_PORT}
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
export POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 \
       POSTGRES_USER=postgres POSTGRES_PASSWORD=change-me
yarn start

Nothing visible changes, which is the point. By default Backstage tries to create a separate database per plugin, so either give the user permission to create databases or pre-create them (plugin database docs). The same database guide also covers passwordless connections with RDS IAM, Cloud SQL IAM and Microsoft Entra.

Step 3: Add GitHub sign-in and a sign-in resolver

The guest provider is "explicitly disabled for non-development environments" (guest provider docs), so you need a real identity provider before you deploy. Create a GitHub OAuth app at https://github.com/settings/applications/new with homepage http://localhost:3000 and callback http://localhost:7007/api/auth/github/handler/frame, then follow the authentication guide:

# app-config.local.yaml (append)
auth:
  environment: development
  providers:
    guest: {}
    github:
      development:
        clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
        signIn:
          resolvers:
            # Matches GitHub's immutable node ID to the github.com/user-id annotation.
            - resolver: userIdMatchingUserEntityAnnotation
# Backend: the module is already in recent templates' package.json; add is idempotent.
yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-github-provider
# Then in packages/backend/src/index.ts, after the auth-backend line, add:
#   backend.add(import('@backstage/plugin-auth-backend-module-github-provider'));

# Frontend packages for the sign-in page extension:
yarn --cwd packages/app add @backstage/core-plugin-api @backstage/plugin-app-react
// packages/app/src/App.tsx (new frontend system, v1.55 template)
import { createApp } from '@backstage/frontend-defaults';
import catalogPlugin from '@backstage/plugin-catalog/alpha';
import { githubAuthApiRef } from '@backstage/core-plugin-api';
import { SignInPageBlueprint } from '@backstage/plugin-app-react';
import { SignInPage } from '@backstage/core-components';
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import { navModule } from './modules/nav';
import { homeModule } from './modules/home';

const signInPage = SignInPageBlueprint.make({
  params: {
    loader: async () => props => (
      <SignInPage
        {...props}
        provider={{
          id: 'github-auth-provider',
          title: 'GitHub',
          message: 'Sign in using GitHub',
          apiRef: githubAuthApiRef,
        }}
      />
    ),
  },
});

export default createApp({
  features: [
    catalogPlugin,
    navModule,
    homeModule,
    createFrontendModule({ pluginId: 'app', extensions: [signInPage] }),
  ],
});

Signing in now fails with "Failed to sign-in, unable to resolve user identity", because no User entity carries your ID. The annotation wants GitHub's node_id, not the numeric id (well-known annotations):

gh api users/octocat --jq '{login, id, node_id}'
# {"id":583231,"login":"octocat","node_id":"MDQ6VXNlcjU4MzIzMQ=="}
# examples/org.yaml (append). In production, load users with the GitHub org provider instead.
---
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
  name: octocat
  annotations:
    github.com/user-id: 'MDQ6VXNlcjU4MzIzMQ=='
spec:
  memberOf: [guests]

Step 4: Register services in the software catalog

The catalog is the part of Backstage that earns its keep. Each repository gets a catalog-info.yaml describing what it is and, more importantly, who owns it (descriptor format):

# catalog-info.yaml at the root of github.com/acme/payments-api
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: payments-api
  description: Card payment authorisation service
  annotations:
    github.com/project-slug: acme/payments-api
    backstage.io/techdocs-ref: dir:.        # docs live next to this file (Step 6)
  tags: [go, payments]
spec:
  type: service
  lifecycle: production
  owner: group:default/payments-team         # must resolve to a Group entity
  system: checkout
  providesApis: [payments-api-v2]

Give the backend a GitHub token (the template already reads ${GITHUB_TOKEN} under integrations.github), then either register single files or discover a whole organisation. For a handful of repos, static locations are fine:

# app-config.local.yaml (append)
catalog:
  locations:
    - type: url
      target: https://github.com/acme/payments-api/blob/main/catalog-info.yaml
    - type: url
      target: https://github.com/acme/platform-org/blob/main/org.yaml
      rules:
        - allow: [User, Group]

For more than a handful, install the GitHub entity provider, which the discovery docs call the preferred ingestion method:

yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-github
# index.ts: backend.add(import('@backstage/plugin-catalog-backend-module-github'));
catalog:
  providers:
    github:
      acmeOrg:
        organization: 'acme'
        catalogPath: '/catalog-info.yaml'
        filters:
          branch: 'main'
          repository: '.*'
        schedule:
          frequency: { minutes: 30 }
          timeout: { minutes: 3 }

Note the rules key. The default config only allows Component, System, API, Resource, Location from locations; anything else (Templates, Users, Groups) must be allowed per location, or it is silently rejected and logged.

Step 5: Create a software template (golden path)

Templates are where a portal becomes a platform: a form, a set of steps, and a new repository that is registered in the catalog with an owner from day one. The example template in examples/template/template.yaml is a good base (writing templates):

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: go-service
  title: Go service with CI and docs
spec:
  owner: group:default/platform-team
  type: service
  parameters:
    - title: Service details
      required: [name, owner]
      properties:
        name:
          title: Name
          type: string
          ui:field: EntityNamePicker
        owner:
          title: Owner
          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           # renders ./content with Nunjucks values
      input:
        url: ./content
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}
    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        repoUrl: ${{ parameters.repoUrl }}
        defaultBranch: main
    - 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 }}

Put a content/ folder next to it containing the skeleton, including a catalog-info.yaml that uses ${{ values.name }} and ${{ values.owner }}, a CI workflow, mkdocs.yml and docs/index.md. Register it with a location that has rules: [{ allow: [Template] }]. The PAT used by publish:github needs repo and workflow scopes if the skeleton includes a GitHub Actions file; a GitHub App is the better production choice.

One v1.55 change to know: github:repo:create dropped a list of inputs it used to ignore silently, such as protectDefaultBranch, so old templates that passed them now fail validation. Move branch protection to github:repo:push (v1.55 release notes).

Step 6: Turn on TechDocs

TechDocs is already wired into new apps. Each repo needs an mkdocs.yml and a docs/ folder (creating and publishing):

# mkdocs.yml at the repo root, next to catalog-info.yaml
site_name: 'payments-api'
nav:
  - Home: index.md
plugins:
  - techdocs-core

The default builder: 'local' with runIn: 'docker' makes the backend spawn a container to run MkDocs, which breaks inside a Kubernetes pod. The TechDocs docs recommend generating in CI and having Backstage only read the output:

# app-config.production.yaml
techdocs:
  builder: 'external'
  publisher:
    type: 'awsS3'
    awsS3:
      bucketName: ${TECHDOCS_S3_BUCKET_NAME}
# In each repo's CI job (Python 3.11+). Pin versions you have tested.
pip install mkdocs-techdocs-core==1.7.1
npx @techdocs/cli@1.12.0 generate --no-docker --source-dir . --output-dir ./site
npx @techdocs/cli@1.12.0 publish --publisher-type awsS3 \
  --storage-name "$TECHDOCS_S3_BUCKET_NAME" --entity default/Component/payments-api

Since v1.55, TechDocs strips MkDocs plugins that are not on its built-in allowlist and logs a warning. If your diagrams vanish after upgrading, add the plugin to techdocs.generator.mkdocs.dangerouslyAllowAdditionalPlugins, and read the name of that setting twice before you do.

Step 7: Build the image and deploy to Kubernetes with Helm

Backstage runs as a stateless backend that also serves the frontend, with state in PostgreSQL. The Docker guide recommends a host build, then the generated packages/backend/Dockerfile (based on node:24-trixie-slim):

yarn install --immutable
yarn tsc
yarn build:backend
docker image build . -f packages/backend/Dockerfile --tag ghcr.io/acme/backstage:1.55.3-1
docker push ghcr.io/acme/backstage:1.55.3-1

Move the GitHub provider into app-config.production.yaml under auth.environment: production with a production: key, remove guest: {} there, and replace the file catalog locations with URLs or the GitHub provider. Then create the secrets and install the Helm chart:

kubectl create namespace backstage
kubectl -n backstage create secret generic backstage-secrets \
  --from-literal=POSTGRES_HOST=backstage-db.internal \
  --from-literal=POSTGRES_PORT=5432 \
  --from-literal=POSTGRES_USER=backstage \
  --from-literal=POSTGRES_PASSWORD='use-a-secret-manager' \
  --from-literal=GITHUB_TOKEN='ghp_example' \
  --from-literal=AUTH_GITHUB_CLIENT_ID='example' \
  --from-literal=AUTH_GITHUB_CLIENT_SECRET='example'
# values.yaml for backstage/backstage chart 2.10.2
backstage:
  image:
    registry: ghcr.io
    repository: acme/backstage
    tag: "1.55.3-1"
  # The chart overrides the Dockerfile CMD, so pass the baked-in config files explicitly.
  args: ["--config", "app-config.yaml", "--config", "app-config.production.yaml"]
  extraEnvVarsSecrets:
    - backstage-secrets
  # One pod at a time while plugin migrations run on startup (chart README advice).
  strategy:
    type: Recreate
  appConfig:
    app:
      baseUrl: https://backstage.example.com
    backend:
      baseUrl: https://backstage.example.com
      cors:
        origin: https://backstage.example.com
ingress:
  enabled: true
  className: nginx
  host: backstage.example.com
  tls:
    enabled: true
    secretName: backstage-tls
helm repo add backstage https://backstage.github.io/charts
helm upgrade --install backstage backstage/backstage \
  --version 2.10.2 -n backstage -f values.yaml
kubectl -n backstage rollout status deploy/backstage

The chart's default probes hit /.backstage/health/v1/readiness and /.backstage/health/v1/liveness, which the new backend serves out of the box. Update the GitHub OAuth app's homepage and callback URLs to the real hostname. If you are moving off ingress-nginx, the Gateway API migration guide covers the replacement.

Step 8: Decide whether to run Backstage yourself

Backstage is a framework, and you are signing up to maintain a TypeScript monorepo with monthly releases. Upgrades are yarn backstage-cli versions:bump plus reading release notes, roughly monthly. If that is not a cost you want, the commercial options are real:

Option Model Published pricing (checked 20 Oct 2026)
Backstage (self-hosted) Open source, CNCF incubating Free; you pay in engineering time
Roadie Hosted Backstage Teams plan $24 per developer per month for 50 to 150 developers, listed for existing subscribers
Port Proprietary SaaS portal Free up to 15 seats; Basic $30 and Standard $40 per seat per month, billed annually
Cortex Proprietary SaaS, scorecard-led No public price list that I could find

My rule of thumb: self-host Backstage if you have engineers who will treat it as a product and you want custom plugins; buy if you mostly need a catalog and scorecards.

Troubleshooting

Symptom Likely cause Fix
create-app exits with a Node version error Node 20 or an odd-numbered release nvm install 24 && nvm use 24; the template requires 22 || 24
"Failed to sign-in, unable to resolve user identity" No User entity with a matching github.com/user-id Add the User with the GitHub node_id (not numeric id), or ingest users with the GitHub org provider
Sign-in popup shows a redirect_uri mismatch OAuth callback URL does not match backend.baseUrl Set callback to <backend.baseUrl>/api/auth/github/handler/frame
Catalog empty after every restart Still on in-memory SQLite Configure backend.database.client: pg (Step 2)
Template or Group never appears in the catalog Location rules do not allow that kind Add rules: [{ allow: [Template] }] (or User, Group) to that location
Entity shows "owner not found" relations spec.owner points to a Group that is not ingested Load the Group, or fix the ref to group:default/<name>
TechDocs build fails in the pod with a Docker error runIn: docker inside a container Use builder: external and generate in CI, or runIn: local with mkdocs installed in the image
TechDocs page missing diagrams after upgrade to 1.55 MkDocs plugin removed by the new allowlist Add it to techdocs.generator.mkdocs.dangerouslyAllowAdditionalPlugins
Pod starts with SQLite and guest config after Helm install Chart command overrides the image CMD, so production config is never loaded Set backstage.args to pass both --config files
Browser shows CORS errors after deploy backend.cors.origin or app.baseUrl still localhost Set both base URLs and cors.origin to the public hostname

FAQ

Is Backstage free?

Yes. Backstage is open source under the Apache-2.0 licence and a CNCF incubating project. The cost is the engineering time to run, upgrade and extend it, which is why hosted options such as Roadie exist.

What is the new frontend system in Backstage?

It is a declarative way to compose the UI from extensions and config instead of hand-written React routes. Since v1.49.0, apps created with create-app use it by default, and --legacy creates an app on the old system.

Do I need Kubernetes to run Backstage?

No. Backstage is a Node.js backend plus static frontend with PostgreSQL, so any container platform or a VM works. Kubernetes is common because the Helm chart and health endpoints make it straightforward.

How long does a production Backstage setup take?

The steps in this guide take an afternoon. Getting real adoption takes much longer, because the catalog is only useful once ownership data is complete and the templates encode paths teams actually want.

What I check first in production

When a Backstage instance misbehaves, I look at three things before anything else: the catalog's processing errors (the catalog-backend-module-logs module in the default backend prints them), whether every Component has a resolvable owner, and whether the pod actually loaded the config files I think it did. Most "Backstage is broken" reports trace back to one of those. I would also keep the portal on the same delivery discipline as any service: pinned versions, an upgrade each month, and the same secret handling you would give a production app. (Disclosure: I build PandaStack, an open-source microVM platform, and I am not affiliated with Backstage, Roadie, Port or Cortex.) The official references are the Backstage docs, the release notes and the Helm chart README; for broader context on platforms and agents, see Kubernetes as the Agent Control Plane in 2026.

Related: Platform Engineering vs DevOps vs SRE in 2026, Kubernetes as the Agent Control Plane in 2026 and DevOps Still Matters in 2026.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related