Technical deep dive

Backstage migration

How a ~106k-line legacy Backstage portal is being replaced by a slim core instance that consumes independently versioned plugin repositories — the architecture, the organization-scoped entity and permission model, the phased execution, and the cutover plan.

Why the migration exists

The legacy portal grew into a single repository holding 24 plugins plus a 26–34k-line application monolith. Three hub files (App.tsx, the backend index, and the permission policy) had to change for nearly every feature, producing structural merge conflicts and repeated production and development outages. Overall statement coverage sat near 32% against an organizational hard requirement of 80%.

The structural root cause was modeling Business Units as catalog Domain entities, with a service that rewired Backstage internals and entangled the GitHub backend, RBAC, and provisioning into one inseparable knot. The migration removes that layer entirely: the GitHub organization becomes the first-class grouping, expressed as the entity namespace throughout the catalog.

Target architecture

Per-topic plugin repositoriesEight workspaces (grafana, github-org, rbac, terraform, dataproxy, infoblox, scorecards, portal-home), each following community-plugins conventions: changesets, ADR011 package naming, a pinned Backstage version, and API reports.
Slim core instanceThe refreshed instance runs the new frontend system (feature discovery over installed packages) and the new backend system (one explicit registration per plugin). The app shell is roughly 241 lines, replacing the 34k-line monolith.
Release-based distributionEvery repository builds publish-ready packages; CI packs tarballs and attaches them to per-package GitHub Releases that the instance consumes. Swapping to a private npm registry later is a configuration-only change.
Isolation rulesPlugin workspaces share no source code; cross-workspace needs become published common/node packages or HTTP contracts. Custom annotation keys are standardized under a single organization-owned namespace.

The organization-scoped model

Business Unit → GitHub organizationThe Business-Unit hierarchy layer is dropped rather than ported. The GitHub organization, lowercased, becomes the entity namespace, and a reviewed mapping table moves existing Business Units onto their organizations, with unmapped strays failing loudly for manual disposition.
Two-tier RBACA config-declared admin team receives full access. Everyone else gets catalog reads filtered at the database level by their organization memberships (plus entity ownership), can run scaffolder templates and use read-only plugins, and is denied other writes.
Sign-in with organization claimsA custom GitHub sign-in resolver fetches the user's organizations with pagination (fixing a truncation bug in an earlier attempt) and issues synthetic group claims that the permission policy consumes.
Business Units return, org-alignedA later decision brings Business Units back as one-to-one, organization-aligned Domain entities with a sidebar tab — without resurrecting the hierarchy layer or the internals rewiring that caused the original problems.

Phased execution

Completed

Phases 0–1 — Foundation and the Grafana pilot

Completed August 9, 2026

The instance was bumped to the latest stable Backstage, release tooling and runbooks were written, and committed secrets were untracked. The Grafana plugin family was then extracted into the first standalone repository and released, proving the full publish-and-consume loop end to end. The pilot doubled as the calibration unit for every later estimate.

Completed

Phase 2 — github-org, the hardest split

Code complete August 9, 2026

The 14.5k-line legacy GitHub backend was split into five packages, with all 27 legacy endpoints dispositioned through a porting map. Business-Unit semantics were removed entirely in favor of the organization model. The package family shipped with 488 tests and at least 80% coverage each, and a tested catalog data-migration script (dry-run and write modes) handles the entity move. Two latent legacy bugs — an unauthenticated pull-requests endpoint and inconsistent admin repository names — were found and fixed during the rewrite.

Completed

Phase 3 — RBAC overhaul

Code complete August 9, 2026

A clean-room build of the two-tier policy and the organization-claims sign-in resolver replaced the stock provider and the allow-all policy. Useful pieces of a colleague's unmerged work-in-progress branch were harvested as reference after a documented harvest-versus-discard analysis, so nothing of value was lost. The modules ship 45 tests at 97% coverage and were boot-verified in the instance.

Completed

Phases 4 and 5a — Terraform provisioning stack and shared types

Code complete August 10, 2026; released and wired in August 11

The largest workspace: 14 packages and roughly 75k lines including tests, spanning the Terraform backend and frontend, 25 scaffolder field extensions, 66 scaffolder actions, and a provisioning frontend — 1,141 tests in the workspace. A small shared-types package published first (phase 5a) eliminated a deep source import that would have broken on publish and unblocked cross-repository dependencies.

Ongoing

Phases 5–6 — Remaining plugin families and app decomposition

In flight as of August 13–14, 2026

The dataproxy family and the small infoblox module are the remaining ports, followed by decomposing what is left of the application monolith (scorecards, tech radar, portal home) and distributing entity cards to their topic workspaces. Of the 36 planned packages, 28 were done with 5 in flight as of August 12. A 27-template update train converts scaffolder templates to the organization model, with every template writing through pull requests only.

Planned

Phase 7 — Deployment, parallel run, and cutover

Scheduled August 14–18, 2026

The agreed sequence: a technical review workshop with the team on August 14 (sign-off from named reviewers is a hard prerequisite for any switchover decision), GitOps rollout to a dedicated namespace in the existing Kubernetes cluster by August 15, a compressed page-by-page parallel-run comparison on August 17, and the refreshed instance live at its own URL on August 18. Both instances coexist afterwards; there is no legacy code freeze — every pull request merged to legacy is ported into the plugin repositories — and the DNS and auth switchover date is decided only after the working solution passes review.

Quality and velocity results

CoverageEvery ported package ships with at least 80% statement coverage from day one, against a legacy baseline near 32%; several packages sit in the 90s or at 100%.
TestsRoughly 1,675 new tests across the plugin repositories so far, in a codebase whose legacy counterpart largely lacked them.
Audited rewritesPorts are rewrites, not copies: unsafe casts, debug logging, and dead paths were removed, deviations are documented per package, and two latent legacy bugs were fixed along the way.
ScaleAbout 110k lines of TypeScript now live in the new workspaces, replacing about 106k lines of legacy custom code — the growth is the test suites the legacy code never had.
Schedule compressionThe original plan estimated 10–16 elapsed weeks. Measured throughput completed the first five phases in roughly two and a half working days, leaving about one to one and a half engineering weeks of porting work.
The real bottleneckEngineering stopped being the schedule driver early. Decision gates and operations tasks — secret rotation, repository remotes, OAuth scopes, GitHub App installs, and the parallel-run window — now set the pace.

Decision discipline

Twenty-seven numbered decisions govern the migration, each recorded with its date, rationale, and any later amendment. Highlights: one repository per topic following community conventions; release-based distribution now with a registry as the future state; the organization model replacing Business Units, then Business Units returning in a constrained org-aligned form; templates writing only through pull requests; no legacy freeze, with merged legacy changes ported forward instead; and plugin repositories staying private with team access granted through an admin group.

Conflicts between planning documents and reality were resolved in writing — for example, a permission module described in meeting material turned out to exist only on an unmerged branch, and the documented resolution (a clean-room rebuild that harvests the good pieces) let the original author verify nothing was lost.

Supporting sources: migration plan, decisions log, executive summary, work-done summary, and timeline documents from the migration roundup, August 8–14, 2026.