Building an Internal Developer Portal with Backstage 1.34
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.yamlin 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
localmode 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.yamlwith HTTP 404 from a private repo. The GitHub app installation is missing theContents: Readpermission, 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 forFailed to find a matching user entity. Usually the catalog hasn’t ingested the GitHub team yet because the schedule hasn’t fired. Run the catalogprocessorendpoint 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 incatalog-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.