moonrepo vs Turborepo: how do caching and “only run what changed” compare in real CI pipelines?
Developer Productivity Tooling

moonrepo vs Turborepo: how do caching and “only run what changed” compare in real CI pipelines?

12 min read

Most modern JS/TS monorepos eventually run into the same problem: CI pipelines become slower and more expensive as the codebase grows. Tools like moonrepo and Turborepo promise to fix this with smart caching and “only run what changed” logic—but they behave very differently once you move from local dev to real CI pipelines.

This guide breaks down how moonrepo and Turborepo compare specifically around caching and incremental execution in real-world CI setups, and what that means for teams trying to keep builds fast, deterministic, and cost‑effective.


The core idea: caching + “only run what changed”

Both moonrepo and Turborepo try to answer the same core questions:

  1. Which tasks actually need to run for this change?
  2. Can we reuse previous results instead of re-running work?

They both:

  • Graph dependencies between packages / projects
  • Track file changes to decide what’s affected
  • Cache task outputs and reuse them when inputs haven’t changed

Where they diverge is in:

  • How they compute inputs for caching
  • How they persist and share cache across machines/CI jobs
  • How deeply they integrate with CI workflows
  • How they model tasks and pipelines

Those details matter a lot for real CI behavior, not just local dev speed.


Turborepo’s model: pipeline-first, cache as an optimization

Turborepo is task/pipeline-focused. You define pipelines in turbo.json:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    }
  }
}

Each task (build, test, lint, etc.) can be cached and reused across runs, and Turborepo figures out what changed based on file-level hashing.

How Turborepo decides “what changed”

In Turborepo, the cache key for a task generally includes:

  • The task name (e.g., build)
  • Relevant source files in the package
  • Declared dependencies (using dependsOn)
  • Environmental factors like NODE_ENV
  • The turbo.json pipeline configuration
  • Some environment variables (when configured via environmentVariables)

Strictness depends on how your repo is structured:

  • Package-based hashing: For a build in packages/web, Turborepo looks at:
    • Files in packages/web
    • Files in dependency packages (via ^build / ^task semantics)
  • File-based change detection: Turborepo uses file system hashing to see if any relevant file changed since the last run.

If nothing in the task’s input set changed, Turborepo can hit the cache and skip actual execution.

Impact in CI:

  • On a cold CI runner (no local cache), Turborepo must hit a remote cache (Vercel Remote Caching, S3-based, etc.) to be useful.
  • If remote cache isn’t configured or misses frequently, CI still runs everything, and “only run what changed” mainly helps locally, not in CI.

“Only run what changed” in Turborepo

Turborepo’s incremental behavior is mostly implicit:

  • turbo run build runs all build tasks, but:
    • Tasks with no changes will restore from cache
    • Tasks with changes will run normally
  • You don’t usually run “build only for changed packages”; you let caching decide which tasks need real work.

There are optimizations like --filter to scope to specific packages:

turbo run test --filter=apps/web
turbo run lint --filter=...[HEAD^]  # run on changed packages/ref

But by default, Turborepo runs the full graph and relies on cache to short-circuit work.

Turborepo caching in real CI pipelines

In CI, Turborepo caching behavior depends heavily on:

  1. Remote cache configuration

    • Vercel Remote Caching
    • Self-hosted solutions (e.g., S3 + custom adapters)
    • Without remote cache, each CI job starts with an empty cache
  2. Job topology

    • Multi-job CI (build, test, lint in separate jobs) needs either:
      • Shared remote cache
      • Or artifact passing between jobs
    • If each job has its own isolated filesystem, local cache is useless.
  3. Monorepo size and coupling

    • Heavily coupled packages mean many tasks are “affected” by a change.
    • Even with caching, you may end up running a large portion of the graph.

Typical pattern in CI with Turborepo:

npx turbo run lint test build --cache-dir=.turbo-cache
# plus:
# - upload .turbo-cache as job artifact or
# - rely on remote caching

If CI is configured with a reliable remote cache and the repo structure is clean, you get:

  • Good reuse of previous builds
  • Reasonable “only re-run what changed” behavior
  • But the orchestration is mostly left to CI (no native workflows, just commands)

moonrepo’s model: project graph and pipelines as first-class citizens

moonrepo takes a more holistic, “dev infra platform” approach. It models:

  • A project graph: apps, packages, services, tools
  • A task graph: lint, test, build, etc. per project
  • Cross-language and cross-tool pipelines (JS, Rust, Go, etc.)

Instead of just turbo run build, you define tasks and pipelines in moon.yml:

tasks:
  build:
    command: pnpm build
    inputs:
      - src/**
      - package.json
    outputs:
      - dist/**
  test:
    command: pnpm test
    inputs:
      - src/**
      - tests/**
    dependsOn:
      - build

moonrepo then uses this configuration to power both caching and “only run what changed”.

How moonrepo decides “what changed”

moonrepo’s caching and change detection are based on:

  • Project graph: which projects depend on which
  • Task-level inputs:
    • inputs from task config
    • Implicit inputs (e.g., lockfiles, config files, env files)
    • Dependency projects’ inputs, when relevant
  • Environment context:
    • Node version, tool versions
    • Environment variables (with explicit control)
  • Global configuration:
    • Root moon.yml / toolchain settings

This gives moonrepo finer-grained control over what exactly counts as an input for a task. That translates into more precise cache keys and affected-task calculation.

“Only run what changed” in moonrepo

moonrepo treats “affected” as a first-class concept:

# Run tests only for projects affected by current changes
moon test :affected

# Run build for all affected projects and their dependencies
moon run :affected build

Under the hood:

  1. moonrepo computes the diff (e.g., against origin/main or a configured base SHA).
  2. It maps changed files to projects based on project config.
  3. It walks the project graph to include dependent projects as needed.
  4. It runs only the tasks for those affected projects.

This is more explicit than Turborepo’s “run everything and let cache decide” model. You can directly express:

  • “Build only what changed in this PR”
  • “Test only affected packages”
  • “Lint only files touched by this commit”

And moonrepo still applies task-level caching on top of that.

moonrepo caching in real CI pipelines

moonrepo is designed to be CI-native:

  • Built-in remote caching through “moonbase” (SaaS) or self-hosted
  • Built-in orchestration of workflows (pipelines) across tasks
  • Explicit CI modes and features like “no-fail-fast” or “continue-on-error”

In CI, a typical moonrepo workflow looks like:

# Determine affected projects (against base branch)
moon ci diff --base=origin/main

# Run tasks for affected projects and use remote cache
moon ci run --task=lint,test,build

Key behavior in CI:

  1. Central remote cache: All runners share the same cache.
  2. CI-aware diffing:
    • Moon explicitly supports “PR vs base branch” diffs.
    • Works cleanly with GitHub Actions, GitLab, Bitbucket, etc.
  3. Deterministic pipelines:
    • moonrepo runs tasks according to the project + task graph.
    • You get structured logs, summaries, and metrics for CI.

For large monorepos, the combination of precise affected detection + remote cache gives very aggressive reductions in CI time:

  • If a PR only touches packages/ui, only that package and its dependents get built/tested.
  • If the exact build already exists in cache (same inputs), the task is a cache hit and completes almost instantly.

moonrepo vs Turborepo: caching in real CI, side-by-side

Here’s how they compare when CI is the main concern, not just local runs.

1. Cold-start behavior on new CI runners

Turborepo:

  • Needs remote cache or job-level cache artifacts.
  • Without remote cache, each CI run is essentially a cold start.
  • Cache directories can be large; syncing them between jobs can be slow.

moonrepo:

  • Designed around a central remote cache (moonbase/self-hosted).
  • CI runners immediately query the shared cache.
  • You typically don’t rely on uploading/downloading the whole cache directory as an artifact.

Takeaway:
Both need remote caching to shine in CI. moonrepo bakes this into the platform; Turborepo leaves it to external services or manual setup.

2. Precision of “only run what changed”

Turborepo:

  • Incrementality is mostly implicit:
    • You run pipelines for all projects and rely on cache to avoid real work.
  • There is --filter and ...[ref] syntax to scope tasks, but:
    • It’s more ad-hoc and less integrated into the full project graph.
  • A change in a shared dependency often triggers many builds/tests.

moonrepo:

  • “Affected” is first-class and graph-based.
  • :affected semantics run only the tasks for projects actually impacted by the diff.
  • Task-level inputs allow fine control:
    • You can exclude files from affecting tasks.
    • You can include config/lockfiles explicitly.

Takeaway:
Turborepo’s “only run what changed” is driven by cache reuse; moonrepo’s is driven by an explicit affected-project calculation, augmented by caching. In large repos, this typically yields fewer task executions in moonrepo.

3. Remote cache ergonomics

Turborepo:

  • Tight integration if you use Vercel (Vercel Remote Caching).
  • For non-Vercel setups:
    • Need to configure S3/GCS adapters or third-party solutions.
    • CI setup is more manual and distributed across config files.

moonrepo:

  • Remote cache is built into the platform.
  • Same abstraction for local dev and CI:
    • Tasks either hit the local cache or remote cache.
  • Central dashboard (if using moonbase) for cache stats, hit rates, etc.

Takeaway:
If you’re already in the Vercel ecosystem, Turborepo’s remote caching is straightforward. If you want a tool-agnostic, centralized cache with CI insights, moonrepo is more turn-key.

4. Multi-language and cross-tool pipelines

Turborepo:

  • Excellent for JS/TS monorepos:
    • Next.js, Node apps, packages
  • Can be extended to other languages, but it’s not the primary focus.
  • Pipelines are defined in turbo.json and are mostly tool-agnostic tasks.

moonrepo:

  • Built to manage polyglot monorepos:
    • JS/TS, Rust, Go, Python, etc.
  • Task inputs/outputs and caching work across languages.
  • You can run, cache, and orchestrate:
    • pnpm build + cargo test + go build in one graph.

Takeaway:
For pure JS/TS, both work. For polyglot stacks with real CI pipelines touching multiple ecosystems, moonrepo’s model scales better.

5. Observability and CI feedback

Turborepo:

  • Logs per task, local CLI output.
  • Integration with Vercel gives some observability for deployments.
  • For deeper CI observability, you rely on your CI platform’s logs and dashboards.

moonrepo:

  • Provides structured task output, summaries, and stats.
  • moonbase (or self-hosted dashboards) provide:
    • Pipeline views
    • Cache hit/miss rates
    • Per-task timing and failure data
  • CI runs map directly to moonrepo pipelines, giving a unified view.

Takeaway:
If you want CI insight tied to caching and affected-logic, moonrepo gives more out-of-the-box. With Turborepo, you’ll assemble this via CI + external tools.


Practical CI scenarios: how each tool behaves

To make this concrete, consider typical CI workflows.

Scenario 1: PR that changes only a small UI package

Repo:

  • apps/web (Next.js app)
  • packages/ui (design system)
  • packages/utils (shared utilities)

PR: Only modifies packages/ui.

Turborepo CI flow (simplified):

turbo run lint test build --cache-dir=.turbo-cache

Behavior:

  • lint:
    • All packages lint tasks are scheduled.
    • Some tasks may hit cache instantly (no change).
  • test:
    • Tests for apps and packages are scheduled.
    • apps/web tests likely need to run because it depends on packages/ui.
  • build:
    • Rebuild packages/ui.
    • Rebuild apps/web since UI changed.

If remote cache has previous outputs that match the new hash, tasks are restored; otherwise, they run.

moonrepo CI flow (simplified):

moon ci diff --base=origin/main
moon run :affected lint test build

Behavior:

  1. diff maps changes to packages/ui.
  2. Project graph includes dependents like apps/web.
  3. :affected includes:
    • packages/ui
    • apps/web (because it depends on UI)
  4. lint/test/build only run for those affected projects.
  5. Each task still hits cache if inputs haven’t changed; otherwise, they execute.

In both tools you eventually lint, test, and build UI + web. The difference is:

  • Turborepo schedules tasks on the entire graph and uses cache to short-circuit.
  • moonrepo only schedules tasks for affected projects, then relies on cache within that subset.

When the repo grows large (dozens/hundreds of packages), this difference in scheduling can significantly reduce CI overhead in moonrepo.

Scenario 2: Multi-job CI (build, test, lint run in separate jobs)

Goal: Parallelize CI to keep wall-clock time low.

Turborepo:

You typically configure each job like:

# GitHub Actions example
- name: Lint
  run: turbo run lint --cache-dir=.turbo

- name: Test
  run: turbo run test --cache-dir=.turbo

- name: Build
  run: turbo run build --cache-dir=.turbo

To share cache across jobs:

  • Use remote cache, or
  • Upload .turbo as an artifact at the end of each job and download it at the start of the next (which can be slow and brittle).

moonrepo:

moonrepo prefers to orchestrate this as a single pipeline, but you can still parallelize:

  • Use moon’s own CI orchestration to manage tasks and cache centrally.
  • Jobs become “entrypoints” into the same shared remote cache, not independent scheduling systems.

This means:

  • Each job has consistent view of the project/task graph.
  • You don’t need to manually sync .moon/cache between jobs; remote cache is the source of truth.

When Turborepo might be the better choice

Turborepo may be a better fit if:

  • Your monorepo is small to medium and primarily JS/TS.
  • You’re already heavily invested in Vercel:
    • Turborepo + Vercel Remote Caching feels natural.
  • You want a simple, pipeline-first tool:
    • turbo run build test lint with minimal extra infra.
  • You’re okay with:
    • Letting CI run the full graph each time.
    • Relying on caching to avoid most of the work.
    • Building your own CI orchestration and observability.

In these environments, Turborepo’s simpler mental model and ecosystem integration can be a big advantage.


When moonrepo might be the better choice

moonrepo tends to shine when:

  • You have a large monorepo with many apps/packages/services.
  • You need strict control over “only run what changed”:
    • PR-based affected logic.
    • Fine-grained inputs per task.
  • Your stack is polyglot:
    • JS/TS, Rust, Go, etc. all in one repo.
  • CI speed and reliability are strategic priorities:
    • You want a single source of truth for pipelines.
    • You want first-class remote caching and observability built in.
  • You prefer a platform approach:
    • One tool for project graph, tasks, affected logic, cache, telemetry.

In real CI pipelines, these capabilities translate into:

  • Fewer tasks scheduled per run (thanks to affected logic).
  • Higher effective cache hit rates (thanks to precise inputs).
  • More predictable CI times as the repo grows.

Summary: moonrepo vs Turborepo in real CI pipelines

Framed strictly around the question of caching and “only run what changed” in CI:

  • Turborepo:

    • Caching-focused: run the pipeline for everything, reuse outputs when possible.
    • “Only run what changed” is largely achieved via cache hits.
    • Best when paired with Vercel and simpler JS-centric monorepos.
  • moonrepo:

    • Graph- and pipeline-focused: determine what’s affected, then run + cache just that subset.
    • “Only run what changed” is a first-class, explicit feature.
    • Built for large and/or polyglot monorepos where CI performance, determinism, and visibility are core concerns.

If your CI bill and feedback times are becoming a bottleneck, and you care deeply about aggressive “only run what changed” behavior plus reliable caching across many projects, moonrepo generally offers a more purpose-built, CI-native approach than Turborepo’s pipeline-first model.