Monorepo Migration Postmortem: What the Tooling Doesn't Automate
We migrated 14 repositories into a Nx monorepo. The codemod tools handled 70% of the work. The remaining 30% took most of the calendar time.

We merged 14 repositories into an Nx 18.2 monorepo over four months. Codemods and import path rewrites handled about 70% of the mechanical work. The remaining 30%—implicit contracts, CI secrets, tribal build knowledge—consumed most of the calendar time and nearly all of the interpersonal friction.
Starting state
- 14 repos: 3 React apps, 6 Node services, 2 shared libs, 3 infra/terraform roots
- Mixed package managers (npm and yarn 1)
- Cross-repo versioning via published
@company/*packages at 0.x semver nobody trusted - Average cross-repo feature: 4 PRs, 2 days waiting on publish pipelines
Goal: single repo, affected-based CI, one npm install, truthful dependency graph.
What tooling automated well
Nx @nx/devkit generators moved projects into apps/ and libs/ with updated project.json. nx graph immediately showed cycles we pretended did not exist.
jscodeshift codemod converted @company/utils imports to @acme/utils path aliases—12k files touched in one scripted pass.
TypeScript project references (composite: true) enforced build order; nx run-many -t build --all replaced bespoke shell scripts.
Estimated engineer-days saved: ~25 vs manual move.
What tooling did not touch
CI secret sprawl
Each repo had GitHub Actions secrets copy-pasted with slightly different names (AWS_KEY vs AWS_ACCESS_KEY_ID). Nx unified workflows still needed one secret mapping doc; we missed STAGING_DATABASE_URL on repo 9 until staging deploy failed week three.
Environment variable archaeology
Services referenced env vars set in Render/Vercel dashboards, not in code. Grep found process.env.FEATURE_X with zero documentation. Consolidation blocked until we ran services in docker-compose locally and watched them crash—living documentation via failure.
Test flakiness amplification
Flaky tests hidden in isolated repos became everyone's problem on main. CI pass rate dropped from 94% (aggregate) to 71% first week—tests always flaked, but parallel noise masked it.
Two-week flake burn-down before anyone trusted green main again.
Ownership and CODEOWNERS
Nx tags (scope:payments, type:app) helped, but GitHub CODEOWNERS still needed human negotiation. Payments team did not want to review marketing site CSS. We ended with path-based ownership mirroring old repo boundaries—organizational scarring.
Relates to how we frame technical debt balance sheet—migration debt is real line item.
Feature flags operational debt
Seven repos had different LaunchDarkly projects. Consolidation exposed duplicate flag keys with conflicting semantics—see feature flags operational debt. Tooling cannot merge human intent.
Timeline truth
| Phase | Planned | Actual |
|---|---|---|
| Inventory + Nx scaffold | 1 week | 1 week |
| Bulk move + codemods | 2 weeks | 3 weeks |
| CI unified | 1 week | 4 weeks |
| Stabilize flake/fix secrets | — | 3 weeks |
| Delete old repos | 1 day | 6 weeks (political) |
Wins after six months
- Cross-cutting refactor (logging library upgrade): 1 PR vs 8
nx affected -t teston typical PR: 4 min vs 22 min full matrix- New hire clone-to-green: 35 min vs half-day multi-repo dance
What I'd do next
- Migration playbook before move—env var inventory script, secret name normalization, flake quarantine labels in old repos first.
- Keep thin repo facades temporarily—git submodule read-only mirrors for teams not ready; controversial but reduced panic.
- Measure developer survey quarterly; monorepo satisfaction dropped month 2, recovered month 5.
Dependency graph surprises
Post-migration nx graph showed lib utils imported by 11 apps—but utils secretly imported payments-client for one helper. Extracting that helper to utils-core took a week; codemods could not infer intent. Lesson: run dependency-cruiser rules before move, not after.
Remote cache economics
Nx Cloud remote cache saved ~180 CI hours/month at $/month cost breakeven month 2. Cache poisoning scare when someone cached test results with local env flag—fixed with nx.json inputs hashing env vars explicitly.
Legal and compliance artifacts
Fourteen repos meant fourteen export control classifications scattered in README footers. Consolidation forced single review with legal—two weeks calendar, uncovered one repo with incorrect ECCN tag blocking EU contractor access. Monorepo compliance win nobody puts on slide deck.
Release versioning theater
Independent repo semver created illusion of stability—payments-service 2.4.1 with utils 0.3.9 internally incompatible. Monorepo switched to calendar versioning at root with nx affected determining deploy set; customers see one product version.
Documentation debt
Post-migration README pointed to old Confluence URLs for 11 weeks—404 rate on internal docs portal measurable. Single docs site generated from /docs in monorepo; broken link CI on every PR.
Vendor package mirrors
Consolidating npm registries pointed internal packages at Verdaccio mirror—one misconfigured publishConfig leaked private package name to public npm scrape attempt. Lock registry URLs in root .npmrc with CI enforcement.
Build cache invalidation
Changing root tsconfig paths invalidated Nx cache for all 14 projects—expected once, surprised team when Renovate bot bumped TypeScript patch. Communicate "everything rebuild day" in Slack before merge.
Nx moved files. Humans moved trust. Budget calendar time for the second part.
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

