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 repositoriesNine workspaces (grafana, github-org, rbac, terraform, dataproxy, infoblox, admin,
theme, templates), 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, published to a private npm registry (Verdaccio,
deployed in-cluster by Flux) that the instance consumes as plain semver dependencies — this replaced the interim GitHub-Releases +
checksummed-vendored-fetch flow on August 19-20, with the full release history backfilled and integrity-verified. Published versions
are immutable — every change ships as a new version through a pull request carrying a semantic-version changeset, enforced as a CI
check. The registry itself is a documented stop-gap: swapping to a managed 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
Infoblox completed August 12, 2026; dataproxy finishing as of August 17
The infoblox module completed on August 12 (44 tests, 99.2% coverage, with a health probe folded into the client). The dataproxy family is
the last engineering item on the critical path: the whole repository passes type-check, lint, and 167 tests, and the business-services
backend is verified at exact legacy parity, with one picker port, one test suite, and the release-and-wire steps remaining. Of the 36
planned packages, 29 are ported, released, and consumed by the instance. App decomposition (scorecards, tech radar, portal home) follows
after cutover — the pages needed on day one shipped early as shell pages in the instance. A 27-template update train converts scaffolder
templates to the organization model, with every template writing through pull requests only.
Ongoing
Phase 7 — Deployment, parallel run, and cutover to primary
Refresh went live August 20, 2026; own-domain go-live August 27; ongoing as of September 4
The technical review workshop was held on August 14, including a walkthrough of the AI-assisted migration workflow; the reviewers present
(Russell, Zoltan) raised no high-level concern with the repository and packaging approach. Release-artifact ingestion completed, and the
Kubernetes and Flux GitOps scaffold went live on August 16 — base Backstage-plus-Postgres manifests, an overlay using ExternalSecrets so no
plaintext secrets exist in git, and image automation targeting the dedicated backstage-refresh namespace. The refreshed
instance went live at its own URL on August 20. A cutover-readiness review on August 26, after roughly two weeks running in parallel with
legacy, found smoke tests, deployment/rollback scripts, and a legacy rollback alias all validated, and concluded technical readiness was
substantially complete — the remaining gate was stakeholder communication, not further engineering. On August 27, rather than repointing
legacy DNS, the refresh instance was given its own permanent host (portal.dev.providesops.io, production later at
portal.providesops.io) and announced as the primary environment for new feature development; legacy
aspirations.dev.providesops.io moved into maintenance-only mode (bug fixes only) rather than being decommissioned outright.
Porting has continued under the no-freeze policy since: a full sweep of a colleague's month of legacy pull requests produced six port
pull requests in a single day (August 31), a hard catalog-namespace-enforcement rule landed both in the migration script and as a runtime
catalog processor (September 3), and the entire plugin fleet was promoted from 0.x to a stable 1.0.0 across seven pull requests
(September 4). A September 1 stakeholder update confirmed the 80% coverage goal met and set template-path validation as the next
milestone; a separate production namespace and QA-to-production workflow are still required before the non-dev production domain goes
live.
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,900 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.
Hardening on the way to cutover
GitHub rate-limit remediationCatalog 403 errors were root-caused to a five-minute processing loop, unscoped organization scans, a
hardcoded monitor, and a silent unauthenticated fallback. The fix shipped August 14–16 as configuration changes plus a v0.2.0 of
the github-org backend: configurable timeouts and cache lifetimes, caching moved to the framework cache service (also fixing a
cross-user cache leak), request timeouts, and fail-loud credential errors instead of silently dropping to the 60-requests-per-hour
unauthenticated bucket.
Repository conventions baselineContributor and agent conventions were imported from the upstream community-plugins project into
all six repositories: contributing guides, code owners, pull-request templates, agent instruction files, a changeset-verification
workflow, enforced 80% coverage thresholds in CI, a weekly automated Backstage version-bump workflow, and SonarQube scanning.
Release integrityGitHub release immutability was enabled on every plugin repository, carried forward as deny-unpublish
configuration when distribution moved to the private registry. An organization policy blocking Actions from opening pull requests is
still worked around with a GitHub App token in each release workflow (the token remains necessary for the version-bump pull requests
even after the registry swap).
Decision discipline
Seventy-one numbered decisions govern the migration, each recorded with its date, rationale, and any later amendment. Highlights: one
repository per topic following community conventions; distribution through GitHub Releases first, then a private npm registry as the sole
channel (decisions 39-50 cover the registry, its reachability reality, in-cluster CI runners, and the bring-up workarounds with their declared lifespans); 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; plugin repositories staying private with team access granted through an
admin group; release immutability across every plugin repository; artifact hygiene — no tarballs in git and a semantic-version
changeset required on every pull request as a CI check; a reversal of the planned DNS cutover in favor of a permanent own-domain host for
the refresh instance (decision 54); branding moved into its own plugin repository under a hard "everything lives in a plugin" rule
(decision 55); and a hardened entity-namespace rule that forces the catalog namespace to the owning GitHub organization (decision 56).
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, work-remaining tracker, and timeline
documents from the migration roundup, August 8 through September 22, 2026.