For most large TypeScript monorepos, the best setup is Turborepo backed by Depot Cache.
Turborepo understands the dependency graph between packages and tasks. It hashes the inputs that can change each task's result. When the same hash already exists, Turborepo restores the previous output instead of running the task again. Depot Cache makes those outputs available to the entire team, whether the task runs on a developer's laptop or in CI.
A faster runner makes a cache miss less painful. A remote task cache avoids the work entirely.
Configure the task graph first
A monorepo cache is only useful when the build system knows which work is safe to reuse. Start by defining task
dependencies, inputs, and outputs in turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {
"outputs": []
}
}
}^build tells Turborepo to build a package's dependencies before building the package. The input set determines when
the cache becomes invalid. The output set determines what Turborepo saves and restores.
For example, changing an application shouldn't force an unchanged shared package to compile again. Changing a shared package should invalidate that package and every downstream task that depends on it. That's the job of the task graph.
TypeScript project references can make the package boundaries explicit to tsc. The task runner still needs to own
the repository-level graph so builds, tests, linting, and framework output follow the same dependency rules.
Share task output through Depot Cache
Turborepo keeps a local cache by default. That helps the second run on one machine, but it does nothing for a clean CI runner or another developer.
Depot Cache's Turborepo integration turns the local optimization into a shared one. Configure these variables on a developer workstation:
export TURBO_API=https://cache.depot.dev
export TURBO_TOKEN=<your-depot-api-token>
export TURBO_TEAM=<your-depot-org-id>
pnpm turbo run build test lintEvery machine using the same Depot organization can retrieve valid task outputs produced elsewhere. A developer can populate the cache before opening a pull request. CI can populate it for the next developer. Parallel jobs can reuse work from earlier workflow runs without copying build directories between runners.
Depot Cache is available from local development and any CI system. The default retention is 14 days, and retention can be configured by time and total cache size.
Use Turborepo on GitHub Actions runners managed by Depot
Depot GitHub Actions runners automatically configure Turborepo to use Depot Cache. The runner receives the cache endpoint, organization, and short-lived authentication details when the job starts. No custom cache action or long-lived cache token is required in the workflow.
name: Monorepo CI
on:
pull_request:
push:
branches: [main]
jobs:
checks:
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint test buildChanging runs-on to a Depot GitHub Actions runner does two separate things. The job runs on faster compute with an in-memory disk
accelerator, and Turborepo automatically connects to Depot Cache. Depot also accelerates cache operations made through
actions/cache and actions/setup-node, with transfer speeds up to 1000 MiB/s.
The package manager cache in actions/setup-node avoids downloading the same package archives. The Turborepo cache
avoids rerunning TypeScript compilation, tests, linting, and bundling when their inputs haven't changed. Keep both.
They solve different repeated work.
Pick the build system for the repository
Turborepo is the best default for a TypeScript monorepo because it fits existing package scripts, has a small configuration surface, and adds task-level incremental builds without requiring the repository to adopt a new build language.
Bazel with Depot Cache is the stronger choice when the repository is very large, polyglot, or needs strict build definitions across languages and platforms. Bazel creates a graph of build actions, hashes their inputs into action keys, and can restore matching artifacts from Depot Cache. The tradeoff is the work required to define and maintain Bazel targets.
moonrepo with Depot Cache is a good fit when the team wants a Rust-based task runner with explicit workspace and task configuration. Depot GitHub Actions runners also configure moonrepo's remote cache connection automatically.
The build system decides what can be reused. Depot Cache stores and serves the result. Replacing one doesn't replace the other.
Keep the four caches separate
Large TypeScript repositories usually have four different performance concerns:
- Runner performance. CPU, memory, and disk determine how long uncached work takes. The runners improve the machine underneath the job.
- Package caching.
actions/setup-nodeoractions/cacheavoids downloading npm, pnpm, or Yarn packages again. Depot Cache accelerates the GitHub Actions cache API on Depot GitHub Actions runners. - Task output caching. Turborepo, Bazel, or moonrepo decides whether compilation, tests, linting, and bundles can be restored by content hash. Depot Cache shares those entries across machines.
- Docker layer caching. A container build has its own BuildKit graph and cache. Depot container builds keep that layer cache on persistent remote storage. A Turborepo cache doesn't replace a Docker layer cache.
Caching node_modules as one large archive isn't a substitute for any of these. It's coarse, expensive to transfer,
and easy to invalidate. Cache the package manager's download store, then let the task runner cache the outputs of work
that's actually reproducible.
Make cache hits trustworthy
A fast wrong build is worse than a slow correct one. Check these details before judging the hit rate:
- Declare every generated directory that a downstream task needs in
outputs. - Include environment files and environment variables that affect output in the task hash.
- Include shared configuration, lockfiles, and code generators in the relevant input set.
- Don't cache tasks that depend on time, an untracked external service, or other nondeterministic state.
- Keep secrets out of generated output that will be stored remotely.
Start with build, test, and lint. Run the same commit twice, then change one leaf package and inspect which tasks
execute. The second identical run should be almost entirely cache hits. The leaf change should rebuild that package
and its dependents, not the entire repository.
That's the useful benchmark. Not whether the cache directory exists. Whether unchanged work disappears for every developer and every CI job.