Engineering MetricsMarch 16, 2026 · 10 min read

Monorepo Deployment Strategy: How to Measure DORA Metrics When Everything Lives in One Repo

Turborepo, Nx, Bazel, Rush — monorepos have become the default for fast-growing engineering teams. But every standard DORA metrics tool was designed for a world where one repository maps to one service. In a monorepo, that assumption collapses entirely. Here is how to measure deployment frequency, lead time, change failure rate, and MTTR correctly when everything lives in a single repo.

The core rule

In a monorepo, deployment_frequency(repo) does not equal avg(deployment_frequency(service)). You must measure per service, or the number is meaningless.

The monorepo measurement challenge

Classic DORA metrics tooling was built on a simple mental model: one repository, one deployable service, one deployment pipeline. Every commit to main eventually produces one deployment event. Deployment frequency is the count of those events. Lead time is the gap between commit and deploy. The model is clean.

Monorepos shatter that model. In a Turborepo or Nx monorepo, a single git push to main may touch the API service, the web frontend, a shared utility package, a background worker, and a database migration — all at once. Or it might touch only a single component in a single app. There is no predictable relationship between a commit and the number of services it affects.

This has two immediate consequences for DORA measurement. First, if you count repo-level deployments — meaning any pipeline run triggered by a push to main — you will massively overcount deployment frequency. A single commit that triggers five parallel service deployments looks like five deployments at the repo level, even though from the perspective of any individual service, it was one deployment.

Second, if you naively aggregate: if your API deploys 8 times a week and your web app deploys 20 times a week, the average across those two is 14. But the repo as a whole might have 120 workflow runs per week, which is the number a naive tool reports. None of these numbers tell you anything useful about the actual delivery health of either service.

The only measurement that produces actionable DORA data from a monorepo is per-service attribution. Every deployment event must be tagged with the service it deployed.

The two correct ways to measure DORA in a monorepo

1. Per-service measurement

The most direct approach: treat each deployable unit in the monorepo as its own measurement target. A commit that changes apps/api is attributed to the API service deployment, not to the web or worker deployments that happened to run in the same pipeline on a different path.

Concretely, this means each service has its own deployment event stream. In GitHub Actions terms, each service has its own environment (production-api, production-web, production-worker), and each environment produces its own Deployment record via the GitHub Deployments API. Koalr reads per-environment deployment events and computes four-metric DORA separately for each.

This is the approach to default to. It is the most accurate, the most actionable, and the most compatible with how engineering managers actually think about service health. When the API's change failure rate spikes, you want to see it in the API's metrics — not averaged in with the web frontend's clean track record.

2. Per-change-set measurement

The second approach groups commits by which services they affect and then attributes DORA metrics per service per deployment event. Instead of asking "when did the API service deploy?", this approach asks "which commits in this deployment touched the API service, and what is the lead time for each of those commits?"

Change-set measurement is more granular and more useful for lead time analysis. A commit that only changes apps/web should have its lead time measured against the web service deployment timestamp, not the API deployment timestamp. If both apps deploy simultaneously but with different CI durations, conflating their lead times produces a misleading average.

In practice, most teams implement a hybrid: per-service deployment event streams (for deployment frequency and CFR), combined with per-change-set commit attribution (for lead time). This is the approach Koalr uses for monorepo customers.

Affected change detection: knowing which services a commit touches

Before you can attribute deployment events to services, you need to know which services a given commit affects. This is where monorepo tooling earns its keep. Each major monorepo tool has a built-in mechanism for detecting the affected packages given a set of changed files.

Turborepo: filter flag with HEAD comparison

Turborepo's --filter flag supports affected package detection natively. Running turbo run build --filter=[HEAD^1] tells Turborepo to build only the packages that changed between the previous commit and HEAD. The [HEAD^1] syntax is a git range filter — Turborepo walks the dependency graph to find all packages that transitively depend on any changed file.

For CI purposes, replace HEAD^1 with the merge base against your main branch:

Turborepo — affected package detection in CI

# Detect which packages changed vs main
turbo run build --filter=...[origin/main]

# Build and deploy only affected services
turbo run deploy --filter=...[origin/main]

# Output the affected packages as JSON (useful for matrix jobs)
turbo run build --filter=...[origin/main] --dry=json | jq '.packages'

The ... prefix means "include dependencies of the matched packages". Without it, Turborepo only rebuilds the changed package itself, not the downstream packages that depend on it — which would cause you to miss services that need redeployment because a shared library they depend on changed.

Nx: affected graph with dependency analysis

Nx uses a project graph computed from your workspace configuration to determine which projects are affected by a set of changes. The command is:

Nx — affected build and deploy

# Build only affected projects
nx affected:build --base=main

# Run affected tests
nx affected:test --base=main

# Print affected project names (for use in matrix jobs or deploy scripts)
nx affected:apps --base=main --plain

# Full affected pipeline (respects task dependencies)
nx affected --target=deploy --base=main

Nx's dependency graph is more explicit than Turborepo's — you configure it via project.json or inferred from tsconfig.json paths. The tradeoff is more upfront configuration for more precise affected detection. In mixed-language monorepos where Turborepo's JavaScript-centric heuristics do not apply, Nx is often the better choice.

GitHub Actions path filters

Even without Turborepo or Nx, GitHub Actions itself provides a simple form of affected detection through path filters on workflow triggers. Each service gets its own workflow file, which fires only when relevant paths change:

.github/workflows/deploy-api.yml — path-filtered deployment

name: Deploy API
on:
  push:
    branches: [main]
    paths:
      - 'apps/api/**'
      - 'packages/shared/**'   # also trigger if shared packages change
      - 'packages/db/**'       # database schema changes affect the API

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production-api   # ← creates a per-service Deployment record
    steps:
      - uses: actions/checkout@v4
      - name: Deploy API service
        run: ./scripts/deploy-api.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

This approach is simple and effective for most teams. The key insight is that each service's workflow is its own DORA event stream. When deploy-api.yml runs and succeeds, that is one API deployment event. When deploy-web.yml runs and succeeds, that is one web deployment event. The two are independently tracked even if both were triggered by the same commit.

The path filters on packages/shared/** are critical. A change to a shared utility library affects every service that imports it — and all of those services need to be redeployed to pick up the change. Failing to include shared package paths in each service's trigger is one of the most common monorepo CI mistakes.

Bazel: reverse dependency query

For teams using Bazel, the reverse dependency query identifies all build targets that transitively depend on a changed target:

Bazel — affected target detection

# Find all targets that depend on a changed package
bazel query 'rdeps(//..., //packages/shared:lib)'

# Build only affected targets
bazel build $(bazel query 'rdeps(//..., set(//packages/shared:lib //apps/api:server))')

# With Bazel's built-in changed file detection
bazel build //... --build_tag_filters=changed

Bazel's dependency model is the most precise of any monorepo tool — it tracks dependencies at the individual file and target level rather than at the package level. The tradeoff is significant: Bazel has a steep learning curve and a substantial migration cost. For most TypeScript/JavaScript teams, Turborepo or Nx achieves 90% of Bazel's precision at a fraction of the operational complexity.

CI/CD pipeline design for monorepos

The path-filtered per-service workflow approach has a clear structural pattern. Each service owns its own workflow file, its own GitHub Environment, and its own deployment history. Here is a complete example for a monorepo with three services — API, web, and a background worker:

Complete per-service monorepo pipeline structure

.github/workflows/
  deploy-api.yml        # triggers on: apps/api/**, packages/shared/**, packages/db/**
  deploy-web.yml        # triggers on: apps/web/**, packages/shared/**, packages/ui/**
  deploy-worker.yml     # triggers on: apps/worker/**, packages/shared/**, packages/queue/**

# Each workflow follows the same pattern:
name: Deploy Web
on:
  push:
    branches: [main]
    paths:
      - 'apps/web/**'
      - 'packages/shared/**'
      - 'packages/ui/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production-web   # ← unique per service
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec turbo build --filter=@myapp/web
      - name: Deploy to Vercel
        run: vercel deploy --prod
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

Each workflow run produces exactly one deployment record (via the environment: declaration). Each deployment record is tagged with the service name via the environment name suffix. This is the structure that enables clean per-service DORA metrics — each environment is tracked independently in Koalr.

Deployment frequency per service

With the pipeline structure above, deployment frequency measurement becomes straightforward. For each service, count the number of successful deployments to its production environment per day or per week.

In practice, this means tagging each deploy with the service name and tracking per-service deployment history. Two common patterns make this easy:

  • GitHub Environments per service: Use production-api, production-web, production-worker as your environment names. GitHub's Deployments API returns these as separate deployment streams, and Koalr reads each independently.
  • Semantic version tags per service: Tag each deploy with a service-scoped version: deploy/api/v1.2.3, deploy/web/v2.0.1. This gives you a queryable deployment history in git itself, independent of any CI system.

The key insight for DORA measurement: in a three-service monorepo, if the API deploys 8 times a week and the web app deploys 20 times a week and the worker deploys 4 times a week, you have three independent deployment frequency measurements — not one averaged number and not a sum. Each service is benchmarked against the DORA elite threshold (four or more deploys per day) on its own terms.

Lead time in monorepos

Lead time — the gap between when a change is committed and when it reaches production — is where the per-service distinction becomes most practically important. Lead time varies significantly by service, even for commits made at the same moment.

Consider three commits pushed to main together:

  • A commit touching only apps/web: triggers the web deployment workflow, which takes 5 minutes for build and 3 minutes for deploy. Lead time: 8 minutes.
  • A commit touching packages/db: triggers all three workflows (API, web, and worker all depend on the database schema). The API deployment takes 12 minutes. The worker takes 9 minutes. The web takes 8 minutes. Each service gets its own lead time measurement against its own deployment timestamp.
  • A commit touching apps/api and packages/shared: triggers the API workflow (because of the API change) and all three workflows (because of the shared package change). The API gets attributed this commit's lead time against the API deployment. The web and worker get their own lead times against their own deployments.

The correct formula for per-service lead time in a monorepo: for each commit that touches a service (directly or through shared dependencies), lead time is service_deployment.created_at - commit.committer.date. Average across all commits in the measurement window.

Averaging lead time across services — or computing it at the repo level — produces a number that tells you nothing. If the API has a 45-minute lead time and the web has an 8-minute lead time, the average of 26 minutes does not describe either service accurately.

Change failure rate in monorepos

Change failure rate (CFR) requires incident attribution to be meaningful. In a monorepo with multiple services, an incident in the API service is a CFR event for recent API deployments — not for web or worker deployments that happened to occur in the same window.

This requires per-service incident routing. PagerDuty and incident.io both support service-level escalation policies. When an alert fires for the API service, it should be routed to the API service's escalation policy — not to a catch-all policy. Koalr reads this service attribution from PagerDuty's service metadata and correlates it with the corresponding service's recent deployments.

A CFR calculation without service-level incident attribution is meaningless in a monorepo. If the API has three incidents caused by a bad deployment and the web frontend has zero incidents, averaging to 1.5 incidents across both services obscures the problem entirely. The API team has a deployment quality problem; the web team does not. You need to see that distinction to act on it.

Common monorepo DORA mistakes

MistakeSymptomFix
Repo-level deployment eventsDeployment frequency 3–10× higher than per-service realityUse per-service GitHub Environments; measure per environment
Commit count as deployment proxyCommit ≠ deployment; a commit may affect 0, 1, or 5 servicesCount workflow runs with conclusion: success per environment
Ignoring shared package changesServices miss redeployment when a shared lib changes; stale code in productionInclude packages/** in path triggers for all dependent services
Averaging lead time across servicesA 45-min API lead time averaged with an 8-min web lead time produces a useless numberReport lead time per service; never aggregate across service boundaries
No per-service incident attributionCFR averaged across services hides which service has a deployment quality problemRoute PagerDuty alerts to per-service escalation policies; correlate to service deployments
Mixing hotfixes and features in CFRHotfix deployments inflate deployment frequency without improving quality signalTag hotfix deployments separately; track CFR for feature deployments and hotfix rate independently

Monorepo tooling comparison for CI/CD

The choice of monorepo tool has a meaningful effect on how cleanly you can implement affected-change detection and per-service deployment pipelines. Here is how the major options compare for CI/CD and DORA measurement purposes:

Turborepo

Turborepo is the default choice for JavaScript and TypeScript monorepos. It is backed by Vercel, has excellent remote caching (both via Vercel Remote Cache and self-hosted options), and integrates seamlessly with the npm/pnpm ecosystem. Affected package detection via --filter=[HEAD^1] is built in and works well for most codebases.

Turborepo's main limitation is that its dependency graph is derived from package.json dependencies and workspace configuration. It does not track file-level dependencies — if service A reads a config file from service B's directory without declaring a package dependency, Turborepo will not detect that A is affected by changes to B. This is usually not a problem for well-structured monorepos but can cause missed builds in legacy codebases with informal cross-package references.

Nx

Nx provides more sophisticated dependency analysis than Turborepo, including the ability to define explicit project dependencies and to infer them from TypeScript path mappings, ESLint configurations, and more. The nx affected command is well-suited to CI pipelines and supports both project-level and task-level affected detection.

The tradeoff is complexity. Nx requires more explicit configuration upfront and has a steeper learning curve, particularly for teams migrating from a multi-repo setup. For mixed-language monorepos (for example, a TypeScript frontend alongside a Go backend), Nx can model the dependency graph across language boundaries in ways Turborepo cannot.

Bazel

Bazel is the most powerful option for large, complex, mixed-language monorepos. Its dependency model is the most precise available — dependencies are declared at the target level (individual libraries, binaries, and tests), not the package level. The rdeps query can identify exactly which build targets are affected by a change to a specific file.

Bazel's cost is significant: it requires dedicated infrastructure knowledge, a migration period measured in months rather than days, and ongoing maintenance. For most teams under 200 engineers, Turborepo or Nx achieves 90% of Bazel's precision at a fraction of the operational overhead. Bazel is the right choice for organizations that have already outgrown the simpler tools — not as a starting point.

Rush

Rush is Microsoft's monorepo manager, designed specifically for large TypeScript monorepos with strict versioning policies. It handles multi-package publishing workflows, change file management, and version bumping better than the other tools. For teams that publish multiple npm packages from a single monorepo, Rush's change tracking model is useful.

For CI/CD and DORA measurement purposes, Rush is less commonly used than Turborepo or Nx. Its affected detection is less mature, and the ecosystem of integrations is smaller. Teams using Rush for publishing workflows often pair it with Nx or Turborepo for the CI/CD pipeline.

Implementing per-service Git deployment tags

An underused technique for monorepo DORA measurement is service-scoped git tags. Tagging each successful deployment with a structured tag name gives you a queryable deployment history in the git object store itself, independent of any CI platform or metrics tool:

Per-service deployment tagging in GitHub Actions

# In your deploy-api.yml workflow
- name: Tag successful deployment
  if: success()
  run: |
    TAG="deploy/api/${{ github.run_number }}"
    git tag "$TAG" ${{ github.sha }}
    git push origin "$TAG"

# Query deployment history per service
git tag --list 'deploy/api/*' --sort=-version:refname | head -20

# Compute deployment frequency from tags
git log --tags=deploy/api/* --simplify-by-decoration   --pretty="format:%D %ci" --since="30 days ago"

# Lead time: time between commit and deployment tag
git log --pretty=format:"%H %ci" apps/api/ | head -1
# vs
git log --tags=deploy/api/latest --pretty=format:"%ci" -1

Structured deployment tags are useful even if you use a dedicated DORA metrics tool, because they make it easy to answer questions like "what was deployed to the API service between these two dates?" or "which commits were included in the last three API deployments?" directly from the git CLI, without needing to query an external system.

Koalr and monorepos

Koalr reads the GitHub Environments API to correctly attribute per-service deployments in monorepos. Each environment (production-api, production-web, etc.) is tracked as an independent service, with its own deployment frequency, lead time, CFR, and MTTR computed separately.

For monorepo teams using GitHub Actions path filters, Koalr automatically detects the service separation from the environment name. No additional configuration is required if you follow the per-service environment naming convention. For teams using matrix strategies in a single workflow file, Koalr reads the per-job environment declaration to separate the deployment events.

Koalr's AI chat panel can answer per-service questions against live monorepo data:

"Which service in our monorepo has the highest lead time this month?"
"Compare deployment frequency across our API, web, and worker services for Q1."
"Which services were affected by changes to packages/shared in the last 30 days?"
"Show me the change failure rate per service for the past 90 days."
"Which commits took the longest to reach production in the API service?"

Summary: the monorepo DORA checklist

If you are setting up DORA measurement for a monorepo, work through these steps in order:

  1. Establish per-service GitHub Environments. Each deployable service gets its own environment name: production-api, production-web, etc.
  2. Add path filters to each service workflow. Include both the service's own directory and all shared packages it depends on.
  3. Verify affected detection coverage. Test that a change to a shared library triggers all dependent service workflows — not just the package that changed.
  4. Configure per-service incident routing. In PagerDuty or incident.io, route service-specific alerts to service-specific escalation policies.
  5. Measure DORA per service, never per repo. Reject any tool or report that gives you a single deployment frequency number for a multi-service monorepo.
  6. Track lead time per change set, per service. A commit's lead time is measured against the deployment timestamp of the specific service it affected — not the first deployment that ran after the commit, regardless of service.

Monorepos are not inherently harder to measure than multi-repo architectures — they just require more explicit service boundaries in the CI/CD pipeline. The teams that get clean DORA data from monorepos are the ones that treat the pipeline as a first-class engineering system, with the same care they give to the services themselves.

Watch out for shared package cascades

A change to a foundational shared package — database client, auth utilities, config types — can trigger deployments across every service in the monorepo. This is correct behaviour, not a bug. But it means that seemingly minor changes to shared packages can appear as a spike in deployment frequency across all services. Tag shared-package-triggered deployments distinctly so you can separate them from feature deployments when reviewing trends.

Per-service DORA metrics for your monorepo

Koalr reads GitHub Environments to correctly separate monorepo services and compute deployment frequency, lead time, change failure rate, and MTTR independently for each. Connect your GitHub organisation and see per-service DORA metrics in minutes — no workflow changes required if you already use GitHub Environments.