Executive Summary
The internal developer portal had grown into a single repository holding 24 custom plugins plus a 34.8k-line application monolith. Unrelated feature work converged on the same handful of central files, so merge conflicts were routine, and the platform had taken production and development down completely more than once. Whole-codebase automated test coverage sat at 31.8% against an organizational hard requirement of 80%.
Over a 12-day window in August 2026 I led the migration to a slim core instance that consumes nine independently versioned, topic-scoped plugin repositories. The app shell shrank to roughly 241 lines; every ported package ships with at least 80% statement coverage from day one.
The Problem: A Monolith That Owned Every Change
The legacy portal held 1,511 commits from a six-month build. Locally, 65% of its custom code was already organised into plugins, but the largest
concentration — packages/app/src, 34,760 lines across 92 files — was still a monolith, with the catalog components alone at 10,118
lines. A small set of files acted as mandatory hub touchpoints for unrelated features:
app-config.production.yaml— 91 modifications in 90 days; every integration added its own config block.permission.ts— 33 modifications; hand-rolled, central permission logic.App.tsx— 30 modifications; manual plugin import and route registration for the entire application.github-backend/src/router.ts— 26 modifications; one central Express router every new endpoint appended to.
The consequence was structural rather than incidental: roughly half of pull requests had to go back to development because another PR had modified the same internal file first. Two separate sprints carried one engineer doing nothing but removing dead code, unused dependencies and barrel exports — 185 of 344 commits in a 28-day window were test, cleanup, refactor or fix commits. And the coverage the organization actually measured, across every source file rather than only the files already under test, was 31.8% against a hard 80% requirement — a ~48-point gap, not the ~6-point gap a partial coverage report suggested.
The Architecture: One Repository Per Topic
The refresh inverted the dependency: instead of one repository owning a monolithic application, a thin instance owns nothing but configuration
and consumes versioned plugin packages. Each repository is a community-plugins-style workspace holding one plugin family, drafted by a written
porting map before any code moved, and built only on Backstage's new frontend and backend systems — one explicit
backend.add(...) per feature, no legacy wiring paths.
- Organization as the only model: Business-Unit semantics were removed entirely. The GitHub organization is the catalog namespace, so ownership is derived from where a repository lives rather than from hand-maintained metadata. A 14.5k-line monolithic
github-backendwas split into a backend, a catalog module and frontend packages, with all 27 legacy endpoints dispositioned first. - Ports, not copies: legacy logic was rewritten idiomatically rather than lifted. Unsafe casts, debug logging and dead paths were dropped, and latent legacy bugs were fixed in transit — an unauthenticated pull-requests endpoint, inconsistent hardcoded admin repo names, and a token exposed in a context header.
- Immutable, gated releases: release immutability is enabled on every plugin repository, and every change ships through a pull request carrying a SemVer changeset. A reusable workflow enforces that gate, so nothing is hot-patched and no published artifact is ever re-cut.
Distribution and CI: Solving the Network Reality
Distribution moved from vendored tarballs to a private in-cluster npm registry, deployed by Flux GitOps, with authentication required on every package. Root-causing a registry login failure to the shipped source proved that token minting is structurally impossible with registration disabled there, so registry auth was reworked to an HTTP-basic identity rolled out to every repository.
The CI story turned on a discovery: neither ingress controller on the cluster is internet-facing, so GitHub-hosted runners could never reach the private registry. Rather than wait on a networking change, CI moved inside the cluster — one ephemeral, scale-from-zero Actions Runner Controller (ARC) scale set per repository, with a custom runner image built in-cluster with kaniko (the upstream image registry was unreachable from workstations). A foreign cluster-wide ARC controller was silently deleting our runner resources; it was identified with a version-probe matrix and neutralised with a Kubernetes validating admission policy.
Security and Governance
- Two-tier RBAC: a config-declared admin team receives full access; everyone else is limited to scaffolder and read-only plugins, with catalog reads filtered by organization membership or entity ownership. Paginated organization fetches fixed a >30-organization truncation in the previous implementation.
- No plaintext secrets anywhere: Kubernetes Secrets are synced from Azure Key Vault through External Secrets Operator using workload identity federation — a service-account token exchanged at Entra ID for a managed-identity token. No secret value exists in git, in manifests, or in CI-rendered output.
- Decision log: a running architecture log reached 71 numbered decisions across 27 recorded rounds, covering release governance, RBAC model, distribution and namespace rules.
Outcome
From a blank plan on 8 August 2026 to a production GitOps deployment on 20 August — the first deploy ran hands-off, end to end, with no manual steps. The refresh now runs at its own domain alongside the untouched legacy instance, with no legacy code freeze: every merged legacy pull request is ported into the plugin repositories continuously.
- Scale: about 110k lines of TypeScript in the new workspaces replacing about 106k lines of legacy custom code; 29 of 36 plugin packages ported, released and consumed by the instance.
- Coverage: every ported package enforces an 80% statement-coverage threshold in CI, against a legacy baseline near 32%; roughly 1,900 tests now exist where the legacy counterpart largely had none.
- Releases: the fleet was promoted from 0.x to stable 1.0.0 in a coordinated seven-repository release wave on 4 September 2026, and has continued to ship minor and major releases since.
What Is Honestly Still Open
An accurate migration narrative includes its gaps. The 80% gate is enforced on statements only, aggregated per repository — branch coverage sits below 80% in five of the nine repositories, exactly where the least-exercised authorization and error paths live. Several integrations remain unbuilt or blocked on credentials (ACR, ServiceNow, Akeyless, Tech Radar). Business-unit adoption through the portal is still in flight and not yet demonstrated end to end. And because parity was ported deliberately rather than lifted, untested assumptions in ported code can still surface — which is why both instances run in parallel instead of cutting over by DNS.