background-shape
Building an Internal Developer Portal with Backstage 1.34
October 6, 2025 · 10 min read · by Muhammad Amal programming

TL;DR — Backstage 1.34 finally retires the legacy backend, so every new portal starts on the new backend system. You can have a usable IDP shell wired to GitHub auth, a Postgres catalog, and TechDocs in about an afternoon. The hard part isn’t the install, it’s the catalog model and the discovery loop.

I’ve been running Backstage in production for three years across two companies, and the version 1.34 release in late September 2025 is the cleanest starting point I’ve ever recommended to a team standing one up from scratch. The new backend system is the default, the old createRouter factories are gone, and the scaffolder finally has stable v1 actions for the things you actually use every day.

This tutorial gets you from an empty directory to a portal that has a real catalog, GitHub login, scaffolder templates, and TechDocs rendering, deployed to a Kubernetes cluster. It assumes you have Node.js 22 LTS installed, a GitHub org you can install an app into, and a Postgres database somewhere. If you want a managed alternative once you’ve seen the moving parts, the Roadie hosted option spares you the operations work, but you should still understand the pieces.

For the philosophy of golden paths that sit on top of this portal, the next post in this series covers software templates end to end. Here, I focus on the foundation.

1. Bootstrapping the App with the New Backend

Run npx @backstage/create-app@latest and give the app a name. As of 1.34, the generated packages/backend/src/index.ts looks completely different from the 1.20-era files you’ll find in old tutorials. There’s no createServiceBuilder and no per-plugin createRouter wiring. Everything is declarative.

// packages/backend/src/index.ts
import { createBackend } from '@backstage/backend-defaults';

const backend = createBackend();

backend.add(import('@backstage/plugin-app-backend/alpha'));
backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.add(import('@backstage/plugin-techdocs-backend/alpha'));
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('@backstage/plugin-auth-backend-module-github-provider'));
backend.add(import('@backstage/plugin-permission-backend/alpha'));
backend.add(import('@backstage/plugin-permission-backend-module-allow-all-policy'));

backend.start();

That’s the entire backend entry point. The new system handles dependency injection, lifecycle, logging, and HTTP routing through service interfaces. You wire plugins, you don’t compose Express routers anymore.

The frontend in packages/app/src/App.tsx still uses the familiar createApp flow, but pay attention to the route bindings. With 1.34 the catalog index page expects you to bind catalogIndexPage and catalogEntityPage explicitly, or you’ll get an empty homepage and won’t know why.

// packages/app/src/App.tsx (relevant slice)
const app = createApp({
  apis,
  bindRoutes({ bind }) {
    bind(catalogPlugin.externalRoutes, {
      createComponent: scaffolderPlugin.routes.root,
      viewTechDoc: techdocsPlugin.routes.docRoot,
    });
    bind(scaffolderPlugin.externalRoutes, {
      registerComponent: catalogImportPlugin.routes.importPage,
    });
  },
});

Run yarn install && yarn start and you should see the app come up on port 3000 with the backend on 7007. If you get a Node version warning, you’re on the wrong runtime. Backstage 1.34 requires Node.js 20 or 22, and I strongly recommend 22 LTS because that’s where security patches will land through 2027.

2. Wiring Postgres and Persistent State

The default SQLite database is fine for the laptop, but anything beyond demos needs Postgres. Add this to app-config.yaml:

backend:
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: ${POSTGRES_PORT}
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
      ssl:
        rejectUnauthorized: false
    pluginDivisionMode: schema

The pluginDivisionMode: schema setting is important. It tells the backend to give each plugin its own Postgres schema rather than its own database. You end up with catalog, scaffolder, auth, etc. as schemas inside one logical database, which is much easier to back up and inspect. The default of database requires CREATE DATABASE privileges, which most managed Postgres providers won’t give you.

backstage_prod (database)
+- catalog (schema)
|  +- final_entities
|  +- refresh_state
|  +- locations
+- scaffolder
|  +- tasks
|  +- task_events
+- auth
   +- sessions

For production, pin your Postgres version. I run Postgres 16 because Backstage’s Knex migrations have been hammered against it for a year. Postgres 17 works as of 1.34 but I’ve seen edge cases in the permission plugin under heavy concurrent reads.

3. Catalog Ingestion That Doesn’t Lie

The catalog is the heart of any IDP. If it lies, developers stop trusting the portal and you’ve wasted the project. The honest model is: every entity in the catalog is owned by a YAML file that lives in the repo of the thing it describes. No exceptions, no GUI-created components, no “we’ll fix it later” entries.

Set up catalog-info.yaml discovery against your GitHub org:

# app-config.yaml
catalog:
  providers:
    githubOrg:
      - id: production
        githubUrl: https://github.com
        orgs: [acme-engineering]
        schedule:
          frequency: { hours: 1 }
          timeout: { minutes: 15 }
    github:
      providerId:
        organization: acme-engineering
        catalogPath: /catalog-info.yaml
        filters:
          branch: main
          repository: '.*'
        schedule:
          frequency: { minutes: 30 }
          timeout: { minutes: 10 }

The githubOrg provider pulls users and teams. The github provider scans repositories for catalog-info.yaml files. Together they build the ownership graph. Don’t skip the org provider, because component-to-team ownership without a real team entity ends up as a dead link.

A minimal catalog-info.yaml for a service looks like:

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: payments-api
  description: Handles payment authorization and capture
  annotations:
    github.com/project-slug: acme-engineering/payments-api
    backstage.io/techdocs-ref: dir:.
    backstage.io/kubernetes-id: payments-api
spec:
  type: service
  lifecycle: production
  owner: team-payments
  system: commerce
  providesApis:
    - payments-api-v2
  dependsOn:
    - resource:postgres-payments

Two annotations matter more than the others. github.com/project-slug lights up the GitHub plugin (PRs, releases, workflows). backstage.io/techdocs-ref: dir:. tells TechDocs that the documentation source lives in this repo. Without those, the entity page is a sad placeholder.

4. GitHub Authentication and Identity Resolution

Skip cookie sessions and the guest provider. Configure GitHub OAuth properly from day one:

auth:
  environment: production
  providers:
    github:
      production:
        clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
        signIn:
          resolvers:
            - resolver: usernameMatchingUserEntityName
            - resolver: emailMatchingUserEntityProfileEmail

The two resolvers stack. The first tries to match the GitHub username to a User entity name in the catalog. The second falls back to matching by email. If neither succeeds, sign-in fails, which is what you want, because a user who can’t be matched to a catalog identity has no ownership in the graph and can’t be granted permissions cleanly.

Permissions get configured next. The 1.34 default policy is allow-all, which is fine for a small team but unacceptable once you have anyone outside the core platform group. Replace it with a real policy module:

// packages/backend/src/permissions.ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { policyExtensionPoint } from '@backstage/plugin-permission-node/alpha';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { catalogEntityDeletePermission } from '@backstage/plugin-catalog-common/alpha';

export const customPermissionModule = createBackendModule({
  pluginId: 'permission',
  moduleId: 'custom-policy',
  register(reg) {
    reg.registerInit({
      deps: { policy: policyExtensionPoint },
      async init({ policy }) {
        policy.setPolicy({
          handle: async (request, user) => {
            if (request.permission.name === catalogEntityDeletePermission.name) {
              const isPlatform = user?.identity.ownershipEntityRefs
                .includes('group:default/team-platform');
              return { result: isPlatform ? AuthorizeResult.ALLOW : AuthorizeResult.DENY };
            }
            return { result: AuthorizeResult.ALLOW };
          },
        });
      },
    });
  },
});

Wire it into the backend with backend.add(customPermissionModule) and remove the allow-all module. Now only the platform team can delete catalog entries from the UI.

5. TechDocs and the Build Pipeline

TechDocs is the one feature that makes developers actually use Backstage instead of grumbling about it. Get it right and you’ve sold the portal internally. The trick is the build strategy. Don’t use the default local builder in production. Use the external strategy where docs are built in CI and uploaded to object storage.

techdocs:
  builder: 'external'
  publisher:
    type: 'awsS3'
    awsS3:
      bucketName: 'acme-techdocs'
      region: 'us-east-1'
  generator:
    runIn: 'local'

The CI side runs the techdocs-cli against each repo on every merge to main:

# .github/workflows/techdocs.yml
name: TechDocs Publish
on:
  push:
    branches: [main]
    paths: ['docs/**', 'mkdocs.yml', 'catalog-info.yaml']

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm install -g @techdocs/cli@1.9.0
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/techdocs-publisher
          aws-region: us-east-1
      - run: |
          techdocs-cli generate --no-docker
          techdocs-cli publish \
            --publisher-type awsS3 \
            --storage-name acme-techdocs \
            --entity default/component/${{ github.event.repository.name }}

This pattern keeps the Backstage backend stateless for docs. The portal just reads pre-rendered HTML from S3. Build times stop blocking the UI, and you can scale the backend horizontally without coordinating cache invalidation.

6. Deploying to Kubernetes

The Backstage app and backend run as a single container in production. Build it with the official multi-stage Dockerfile that the scaffolder generates, then ship a Helm-friendly manifest:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backstage
  namespace: backstage
spec:
  replicas: 2
  selector:
    matchLabels: { app: backstage }
  template:
    metadata:
      labels: { app: backstage }
    spec:
      serviceAccountName: backstage
      containers:
        - name: backstage
          image: ghcr.io/acme-engineering/backstage:1.34.3
          ports:
            - containerPort: 7007
          env:
            - name: NODE_ENV
              value: production
            - name: POSTGRES_HOST
              valueFrom: { secretKeyRef: { name: backstage-db, key: host } }
            - name: POSTGRES_PASSWORD
              valueFrom: { secretKeyRef: { name: backstage-db, key: password } }
          resources:
            requests: { cpu: 500m, memory: 1Gi }
            limits: { cpu: 2, memory: 2Gi }
          readinessProbe:
            httpGet: { path: /healthcheck, port: 7007 }
            initialDelaySeconds: 30
          livenessProbe:
            httpGet: { path: /healthcheck, port: 7007 }
            initialDelaySeconds: 60

Two replicas is the floor. The scaffolder uses in-memory task locks by default, and a single replica means task failures during restarts. With two replicas, configure the database-backed task store explicitly:

scaffolder:
  taskTimeout: PT2H
  concurrentTasksPerWorker: 5

The kubectl 1.32 client and the Kubernetes plugin for Backstage talk over the standard API. Annotate workloads with backstage.io/kubernetes-id: <component-name> matching the catalog entity, and the entity page surfaces pods, services, and recent events.

+-------------+      +-------------+      +-----------+
|  Backstage  +----->+  Postgres   |      |    S3     |
|   (k8s)     |      |   (RDS)     |      | (techdocs)|
+------+------+      +-------------+      +-----+-----+
       |                                         ^
       |  read pre-rendered HTML                 |
       +-----------------------------------------+

Common Pitfalls

I’ve seen the same four mistakes burn every team standing up Backstage for the first time.

  • Hand-editing catalog entries via the UI. There’s a button to register entities by URL. Use it once for the initial seed, then delete the button. Every catalog entry should come from a catalog-info.yaml in a repo, or your ownership graph rots within a quarter.
  • Skipping the org provider. Teams want to ship the portal with users imported from Okta or LDAP only. Without the GitHub org provider, code ownership doesn’t line up with portal ownership, and the entity owner field becomes a stringly-typed lie.
  • Running TechDocs in local mode in production. Works for ten repos. Falls over at a hundred. By the time you notice the OOMs in the backend pod, you’ve already trained developers that the docs tab is slow.
  • Leaving the allow-all permission policy on. Two months in, someone deletes the wrong entity. Nobody can recover it cleanly because the audit trail is thin. Set up a real policy on day one.

Troubleshooting

  • Failed to read catalog-info.yaml with HTTP 404 from a private repo. The GitHub app installation is missing the Contents: Read permission, or the app isn’t installed on that specific repository. Check the installation page in the org settings, not just the app definition.
  • Sign-in succeeds but the user has no entity. The resolver chain didn’t match. Watch the backend logs at debug level (LOG_LEVEL=debug) and look for Failed to find a matching user entity. Usually the catalog hasn’t ingested the GitHub team yet because the schedule hasn’t fired. Run the catalog processor endpoint manually to force a refresh.
  • TechDocs pages 404 after a successful publish. The S3 prefix doesn’t match the entity ref the backend computes. The default is default/component/<name> lowercased. If the catalog entity name has uppercase letters, the publisher and the reader disagree. Lowercase the name in catalog-info.yaml.

Wrapping Up

You now have a portal that’s not a demo. It’s wired to GitHub, persists state in Postgres, builds docs in CI, and runs on Kubernetes. From here the work shifts from infrastructure to content. The next investments that pay back fast are golden-path templates that emit a new service plus its catalog-info.yaml in one click, and a kubernetes plugin configuration that surfaces real workload health on entity pages.

See the Backstage docs for the canonical reference, especially the new-backend-system migration notes if you’re moving from a 1.20-era app. The next post in this series picks up from here and gets into the scaffolder.