Three years ago, we put everything in one repo. Google does it, so does Facebook and Microsoft.
Today, our CI has been red for 6 hours because someone updated a shared utility without running the full test suite. Again.
Here's what actually happens with monorepos in practice.
Why Monorepos Look Great
Shared Code Without Package Hell
Direct imports across projects. No publishing, no version mismatches, just clean imports from a shared workspace.
Atomic Changes
Update an API contract and all its consumers in one commit. One PR, one deployment, everything in sync.
Unified Dependencies
One package.json, consistent tooling, same environment for everyone.
The Reality Check
Problems start small but grow exponentially with team size and codebase complexity.
The CI/CD Death Spiral
This is the big one. Not so much raw build time, but the blast radius of automation: pipelines that trigger too often, deploy when they shouldn’t, and block everyone when one thing breaks.
In practice the CI needs careful change detection per app, selective testing, and gated deploys per target. Without those guardrails, triggers and deploys fan out across the workspace.
Trigger Storms and Unintended Deployments
The real pain came from pipelines triggering when they shouldn’t and changes in one area causing deployments for another team:
- Over‑broad change detection: a shared package bump triggers builds for multiple apps even when runtime impact is zero.
- Coupled release steps: deploy jobs wired to generic “main updated” signals instead of per‑app conditions.
- Implicit dependencies: scripts that assume “if shared changed, everything redeploys”.
Result: noise, wasted minutes, and worst of all, deployments landing for teams that didn’t ask for them.
Global Pipeline Coupling
When the monorepo pipeline is red, everyone is red. One broken step blocks builds and deployments for all teams until it’s fixed. That turns a local issue into an organization‑wide outage.
We saw this repeatedly: a flaky test or environment issue in one project halted releases elsewhere that had no functional dependency on the broken piece.
Containment Strategies That Help
- Per‑app pipelines with explicit inputs (paths filters) and required approvals before deploy.
- Selective deploy: only deploy the app/package whose directory changed, even if shared code changed.
- Release branch per app: merge from main → release/
app
to signal intent to deploy that app only. - Feature flags and canaries to decouple deploy from release.
- Contract tests for shared packages so downstreams don’t auto‑deploy on every bump.
If your organization can’t enforce these guardrails, selective monorepos (only closely related projects together) or separate repos with versioned packages often produce saner delivery.
Example: Selective CI with Turbo
Below are generic examples (sanitized) showing how to combine path filters with Turbo to keep builds and deploys scoped to the app that actually changed.
Note: These are illustrative snippets, not copied from any internal repository.
name: CI (Selective)
on:
pull_request:
push:
branches: [ main ]
jobs:
detect:
runs-on: ubuntu-latest
outputs:
web_changed: "+${{ steps.paths.outputs.web }}"
api_changed: "+${{ steps.paths.outputs.api }}"
steps:
- uses: actions/checkout@v4
- name: Detect changed paths
id: paths
uses: dorny/paths-filter@v3
with:
filters: |
web:
- 'apps/web/**'
- 'packages/shared/**'
api:
- 'apps/api/**'
- 'packages/shared/**'
build-web:
needs: detect
if: needs.detect.outputs.web_changed == '+true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Turbo build (scoped)
run: npx turbo run test build --filter=@acme/web-app...
build-api:
needs: detect
if: needs.detect.outputs.api_changed == '+true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Turbo build (scoped)
run: npx turbo run test build --filter=@acme/api...
name: Deploy (Per-app)
on:
workflow_dispatch:
inputs:
app:
description: App to deploy (web|api)
required: true
env:
description: Environment (staging|prod)
required: true
jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: deploy-${{ inputs.app }}-${{ inputs.env }}
cancel-in-progress: true
environment: ${{ inputs.env }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Build selected app
run: |
if [ "${{ inputs.app }}" = "web" ]; then
npx turbo run build --filter=@acme/web-app...
else
npx turbo run build --filter=@acme/api...
fi
- name: Deploy selected app
run: |
echo "Deploying ${{ inputs.app }} to ${{ inputs.env }}"
# kubectl/helm or GitOps commit here
The Multi-Team Coordination Problem
The biggest challenge isn't technical. It's organizational. When multiple teams share a monorepo:
- Shared responsibility becomes no responsibility: Who fixes the broken build at 3 AM?
- Different release cycles clash: Your urgent hotfix is blocked by another team's broken tests
- Code review bottlenecks: Changes affecting shared code need approval from multiple teams
- Dependency hell multiplied: One team's upgrade breaks three other teams' features
- CI resource contention: Long queues when everyone is trying to deploy
GitHub's CODEOWNERS file helps with permissions, but doesn't solve the fundamental coordination problem. Even with perfect access controls, you still need human processes to manage inter-team dependencies.
Git Performance Degrades
Large monorepos mean large git operations:
- Clone time: New developers wait 10+ minutes for initial checkout
- Git history: Thousands of commits across unrelated services
- Branch management: Merge conflicts in unrelated areas
- Git blame: Becomes less useful with frequent cross-service changes
The War Stories
Let me tell you about a logistics platform I worked on. It's a story that starts with optimism and ends with... well, you'll see.
Phase 1: The Honeymoon
Three services, simple scripts, clean dependencies. Everything worked perfectly.
Phase 2: Complexity Creeps In
Eighteen months later:
- Simple npm scripts → Turbo-managed builds with workspace coordination
- 5 scripts → 20+ for coverage, testing, dependencies
- 5-minute dependency installs
- Multi-target deployment orchestration
Phase 3: The Multi-Team Invasion
Then other teams discovered our monorepo. "Hey, this looks useful! Can we add our services here too?"
What seemed like a great idea for code sharing quickly became a coordination nightmare:
- Broken builds: Teams would update shared dependencies without understanding the impact across all packages
- Failed deployments: Changes in one team's feature would break our production deployments
- Merge conflicts: Multiple teams pushing to the same repo created constant integration issues
- Testing bottlenecks: Our CI pipeline became a shared resource that everyone was waiting on
- Rollback complexity: When something broke, we couldn't just revert one service - the entire monorepo needed coordination
The Breaking Point
A translation update broke our core API. 2 AM Friday, right before a client presentation. Another team had modified a shared utility without running the full test suite.
The Escape
Two weeks of extraction to separate repos. Builds fast again, CI simple, deployments independent. Shared utilities became versioned npm packages. Those "critical" atomic commits? Needed twice in six months.
When Monorepos Actually Work
Monorepos can work brilliantly, but only in specific scenarios:
Single Team, Related Services
One team owning web app, admin panel, and API with shared types/utilities works well.
Component Libraries
Design systems and documentation sites thrive in monorepos: components, docs, and examples all together.
Teams Under 20 Developers
Communication overhead stays manageable.
Why Google and Facebook Succeed
Large companies make monorepos work through massive tooling investment: custom build systems (Bazel/Buck), dedicated infrastructure teams, and strict cultural enforcement. They have the resources to solve the problems at scale. Most organizations don't.
Better Alternatives
Multi-repo with Shared Packages
Separate repos with versioned shared packages. Apps upgrade when ready.
Selective Monorepos
Group related services: auth/profiles in one repo, payments in another, frontend apps together.
Lessons Learned
- Start with 2-3 services max: Test the waters before diving in
- Invest in tooling upfront: Nx/Lerna, selective CI, proper git hooks
- Plan your escape: Know how to split when needed
- Conway's Law wins: Separate teams need separate repos
The Verdict
Monorepos work when you have:
- 5-10 people on one team
- Tightly coupled services
- Significant tooling investment
- Aligned release cycles
Multiple teams with different priorities? Use separate repos with well-defined interfaces.
The Bottom Line
Use monorepos for closely related services within team boundaries. Use separate repos for everything else.
Start small, measure pain points, and split when coordination overhead exceeds benefits.
For related insights on modern DevOps practices, see our guide on implementing GitOps with Flux CD, which covers another critical aspect of managing complex deployment workflows.