Moonrepo CI Configuration: Caching, Parallelism, and Remote Caching
Moonrepo's CI performance comes from three compounding optimizations: content-addressed local caching, parallel task execution across dependency levels, and remote caching shared across all runners and branches. Together, they can reduce CI times by 70–90% in large monorepos.
Key Takeaways
- Moon's cache key is a hash of all declared inputs — if nothing changed, the task is skipped instantly.
- Tasks at the same dependency level run in parallel automatically; no explicit configuration needed.
- Remote caching (Moonbase or S3) shares cached results across all CI runners and all developers.
- The --concurrency flag controls how many tasks run simultaneously, tunable per runner size.
- Cache lifetime and invalidation rules are configurable per task and at the workspace level.
The Three Layers of Moonrepo Speed
Most CI slowdowns in monorepos have the same root cause: running everything every time. Moonrepo addresses this at three levels:
- Local caching — skip tasks whose inputs haven't changed since the last run on this machine/runner
- Parallelism — run independent tasks simultaneously rather than one at a time
- Remote caching — share cached results across all machines so no task runs twice anywhere
Understanding how each layer works lets you configure them precisely for your team's needs.
Local Caching: Content-Addressed Task Results
Moon stores task results in .moon/cache/ keyed by a hash of all declared inputs. When you run a task, Moon first computes this hash and checks whether a matching cache entry exists. If it does, Moon restores the outputs and marks the task as a cache hit.
The inputs hash includes:
- All files matching the
inputsglob patterns inmoon.yml - The task command and arguments
- Environment variables listed in
env(if any) - The tool version (Node.js, Rust, etc.) from toolchain config
- Moon's own version
Configure caching behavior per task:
# packages/api/moon.yml
tasks:
test:
command: vitest run
inputs:
- src/**/*
- tests/**/*
- vitest.config.ts
- package.json
outputs:
- coverage
- test-results.xml
options:
cache: true
cacheLifetime: '12 hours'
typecheck:
command: tsc --noEmit
inputs:
- src/**/*
- tsconfig.json
options:
cache: true
cacheLifetime: '24 hours'The outputs field tells Moon what to restore when a cache hit occurs. Outputs are archived and stored alongside the hash in the cache directory.
Workspace-Level Cache Configuration
Set cache defaults in .moon/workspace.yml:
# .moon/workspace.yml
runner:
cacheLifetime: '7 days'
inheritColorsForPipedTasks: true
logRunningCommand: true
vcs:
manager: git
defaultBranch: main
remoteCandidates:
- origin
- upstreamThe cacheLifetime setting determines how long a cache entry remains valid regardless of input changes. This is a safety net — if your inputs list is incomplete, the cache will still invalidate after this period.
Parallelism: The Dependency Graph as an Execution Plan
Moon doesn't just skip unchanged tasks — it also executes independent tasks simultaneously. The dependency graph determines what can run in parallel.
Given this project structure:
apps/
web/ → depends on: ui-components, utils
mobile/ → depends on: ui-components, utils
packages/
ui-components/ → depends on: utils
utils/ → no dependenciesMoon's execution plan for moon run :test looks like:
| Wave | Tasks (run in parallel) |
|---|---|
| 1 | utils:test |
| 2 | ui-components:test |
| 3 | web:test, mobile:test |
Wave 3 runs web:test and mobile:test simultaneously because they have no dependencies on each other.
Controlling Concurrency
By default, Moon uses the number of CPU cores as the concurrency limit. Override this with the --concurrency flag:
# Run at most 4 tasks simultaneously
moon run :<span class="hljs-built_in">test --concurrency 4
<span class="hljs-comment"># Run all tasks as fast as possible (no limit)
moon run :<span class="hljs-built_in">test --concurrency 0For small CI runners (2 vCPU), setting --concurrency 2 avoids thrashing. For large runners (16+ vCPU), increasing concurrency reduces total wall time significantly.
Task-Level Parallelism Options
Some tasks are inherently sequential (database migrations, for example). Mark them as non-parallel:
tasks:
migrate:
command: 'db:migrate'
options:
runInCI: true
cache: false
# Prevent this task from running alongside other tasks
mutex: 'database'The mutex option groups tasks that must not run concurrently, even if they appear at the same level of the dependency graph.
Remote Caching with Moonbase
Local caching only helps within a single machine. When you have multiple CI runners or multiple developers, each machine starts cold. Remote caching shares the cache across all of them.
Moonbase is Moon's official remote cache server. Sign up at moonrepo.app and configure it:
# .moon/workspace.yml
vcs:
manager: git
defaultBranch: main
# Remote caching via Moonbase
moonbase:
host: 'https://moonbase.moonrepo.app'Set the authentication token as an environment variable in CI:
export MOONBASE_SECRET_KEY=your_secret_keyWith remote caching enabled, the workflow becomes:
- Runner A builds and tests
utils:test— result is uploaded to Moonbase - Runner B starts a parallel job — it downloads the cached result from Moonbase and skips execution
- Developer pulls main and runs
moon run utils:test— same cache hit, instant result
Self-Hosted Remote Caching with S3
If Moonbase doesn't fit your infrastructure, Moon supports any S3-compatible object store as a remote cache backend. Use moon-cache-server or configure the S3 backend directly:
# .moon/workspace.yml
runner:
archivableTargets:
- 'packages/**:build'
- 'packages/**:test'Then point your CI to a custom cache server that implements Moon's remote cache protocol. Several open-source implementations exist for AWS S3, Google Cloud Storage, and MinIO.
Full CI Pipeline Example
Here is a complete GitHub Actions workflow using all three optimization layers:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
setup:
runs-on: ubuntu-latest
outputs:
affected: ${{ steps.affected.outputs.result }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: moonrepo/setup-moon-action@v1
with:
auto-install: true
test:
needs: setup
runs-on: ubuntu-latest
env:
MOONBASE_SECRET_KEY: ${{ secrets.MOONBASE_SECRET_KEY }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: moonrepo/setup-moon-action@v1
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run affected tests
run: |
moon run :lint :typecheck :test \
--affected \
--base ${{ github.base_ref }} \
--head ${{ github.sha }} \
--concurrency 4
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/test-results.xml'Matrix Builds with Affected Detection
For very large monorepos, you can combine Moon's affected detection with GitHub Actions matrix builds to parallelize across multiple runners:
jobs:
determine-affected:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.result }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: moonrepo/setup-moon-action@v1
- id: matrix
run: |
AFFECTED=$(moon query projects --affected --base ${{ github.base_ref }} --json)
echo "result=$AFFECTED" >> $GITHUB_OUTPUT
test:
needs: determine-affected
runs-on: ubuntu-latest
strategy:
matrix:
project: ${{ fromJson(needs.determine-affected.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: moonrepo/setup-moon-action@v1
- run: pnpm install --frozen-lockfile
- run: moon run ${{ matrix.project }}:testThis pattern assigns each affected project to its own runner, providing maximum parallelism at the cost of more runner-minutes.
Measuring Cache Effectiveness
Track cache hit rates to understand where time is being saved:
# View cache statistics for the last run
moon run :<span class="hljs-built_in">test --affected 2>&1 <span class="hljs-pipe">| grep -E <span class="hljs-string">"(cache hit|cache miss|skipped)"Moon's output annotates each task with its cache status:
▸ utils:test (cache hit, skipped)
▸ ui-components:test (cache miss, running)
▸ web:test (cache miss, running)A healthy CI pipeline should show 60–80% cache hits on PRs that touch a small portion of the codebase.
Common Pitfalls
Missing inputs cause stale cache. If a file that affects your tests is not listed in inputs, Moon won't know to invalidate the cache when it changes. Be comprehensive with your input globs, and include config files like jest.config.ts, vitest.config.ts, and tsconfig.json.
Environment variables leaking into cache keys. If your test command reads environment variables that differ between developers (like HOME or USER), Moon's hash may differ even when code hasn't changed. Explicitly list env vars in the task config to make them part of the hash — or exclude them.
Large output artifacts slowing remote cache. If your build outputs are hundreds of megabytes, uploading and downloading from Moonbase can take longer than just running the task. Be selective about which tasks have remote caching enabled.
Adding End-to-End Test Coverage
Your CI pipeline handles unit tests, type checking, and linting through Moon. For browser-level end-to-end tests against your deployed apps, HelpMeTest runs tests written in plain English — no Selenium, no Playwright configuration. Drop HelpMeTest into the same CI workflow and cover the user-facing layer that unit tests can't reach.
Summary
Moonrepo's CI performance gains come from stacking three complementary strategies: content-addressed local caching eliminates redundant work within a runner, automatic parallelism collapses independent tasks into concurrent waves, and remote caching extends both benefits across every machine and every developer. Configure your inputs lists carefully, set appropriate cache lifetimes, and wire up Moonbase for remote caching — and your CI times will drop dramatically as your monorepo grows.