Turborepo does one job: it stops a monorepo from repeating work that already ran. Every task execution is hashed against its inputs, the results land in a cache, and the next run that matches replays in milliseconds instead of minutes. On a five-package repo the difference is seconds. On a fifty-package repo it is the difference between a CI bill you notice and one you do not.
This Turborepo monorepo setup guide builds a real pnpm workspace from an empty directory: a Fastify JSON API, a queue-consumer worker, and three shared internal packages, all wired through a turbo.json that uses the current tasks schema. You will run cached builds, test the task graph, build only affected packages, and hit three real errors with their fixes. One thing to clear up before starting, because the names confuse people: Turborepo is a build system for JavaScript and TypeScript repos, while Turbopack is the bundler inside Next.js. Different tools, same vendor.
Everything below was run on Ubuntu 24.04 LTS with Node.js 24.18, pnpm 11.12, and Turborepo 2.10.4 in July 2026. The finished code lives in the turborepo-lab companion repo.
How Turborepo caching works
Turborepo sits on top of your package manager’s workspace feature. pnpm decides how packages link to each other; Turborepo decides which package scripts run, in what order, and whether they need to run at all. Before executing a task it computes a hash from the package’s source files, its dependencies’ hashes, relevant environment variables, and the task configuration. If that hash exists in the cache, the task is skipped and its saved terminal output and build artifacts are restored instead.
Two consequences follow. First, task ordering comes from a graph, not a script: the API cannot build before the logger package it imports, and Turborepo works that out from the workspace dependencies. Second, unrelated tasks run in parallel automatically, which is why even the uncached first run usually beats a hand-written build script on repos with more than a handful of packages.
Prerequisites
Turborepo itself is a single binary installed from npm, so any Linux distribution works. You need:
- A Linux machine or VM. The lab box for this guide was Ubuntu 24.04 LTS with 4 vCPUs and 8 GB RAM, which is comfortable; 2 vCPUs and 2 GB RAM will build this repo fine.
- Node.js 20 or newer. Node.js 24 is the current LTS line and what this guide uses.
git, because Turborepo uses git file hashing to fingerprint task inputs.
Install Node.js and pnpm
Grab Node.js 24 from the NodeSource repository. If you want the distribution-specific details, we have separate guides for installing Node.js on Ubuntu and Node.js 24 on Debian, and a walkthrough for managing multiple Node.js versions if this box already has an older runtime on it. Fedora users get Node through the dev runtimes setup instead.
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash -
sudo apt install -y nodejs
Confirm the runtime:
node --version
The current LTS release prints:
v24.18.0
pnpm ships through Corepack, which comes bundled with Node.js 24 LTS (Node 25 and newer no longer distribute it, so on those versions install it first with npm install -g corepack). Enable it and check the version it pins:
sudo corepack enable pnpm
pnpm --version
Corepack downloads and activates the current pnpm release on first use:
11.12.0
That is the whole toolchain. Everything else installs per-repo.
Scaffold the pnpm workspace
Create the repo skeleton. Apps that deploy live under apps/, shared code lives under packages/:
mkdir -p ~/turborepo-lab/{apps/{api,worker}/src,packages/{logger,config,db}/src}
cd ~/turborepo-lab
The root package.json is private and does nothing except hold workspace-wide scripts and pin the package manager. Create it:
vim package.json
Add:
{
"name": "turborepo-lab",
"private": true,
"packageManager": "[email protected]",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"check-types": "turbo run check-types",
"dev": "turbo run dev"
}
}
The packageManager field matters more than it looks: Corepack reads it and guarantees every developer and every CI runner uses the same pnpm release. Next, tell pnpm where the workspace packages live. This file is what makes the directory a pnpm workspace:
vim pnpm-workspace.yaml
Paste in:
packages:
- "apps/*"
- "packages/*"
allowBuilds:
esbuild: true
The allowBuilds block is a pnpm 11 security feature. pnpm refuses to run dependency postinstall scripts unless you approve them, and esbuild (pulled in by tsx, which the apps use for their dev task) needs its postinstall to fetch a platform binary. Without this block the install prints an ERR_PNPM_IGNORED_BUILDS error; the troubleshooting section at the end shows exactly what that failure looks like.
Finish the skeleton with a .gitignore:
vim .gitignore
Three entries do it. The .turbo/ directory is where the local cache lands, and it never belongs in git:
node_modules/
dist/
.turbo/
The skeleton is done. Time to put packages in it.
Create the shared packages
Three internal packages back the apps: a shared TypeScript config, a structured JSON logger, and an in-memory job store that stands in for a real database. Internal packages need nothing more than a package.json with a name; the @repo/ scope is a convention that makes imports obviously internal.
Start with the config package, which only exports a base tsconfig for everything else to extend:
vim packages/config/package.json
Add:
{
"name": "@repo/config",
"version": "0.0.0",
"private": true,
"files": [
"tsconfig.base.json"
]
}
Then the base compiler options every package inherits:
vim packages/config/tsconfig.base.json
Strict mode, modern module resolution, declarations on:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true
}
}
Notice this package has no build script. That is deliberate, and Turborepo handles it cleanly: when a task graph asks for @repo/config‘s build, Turborepo sees there is no such script and moves on.
Now the logger package:
vim packages/logger/package.json
The exports map points consumers at the compiled output in dist/, and the scripts are what Turborepo will invoke:
{
"name": "@repo/logger",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"check-types": "tsc --noEmit -p tsconfig.json",
"test": "node --test \"dist/**/*.test.js\""
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/node": "^24.0.0",
"typescript": "^5.6.0"
}
}
The workspace:* protocol is pnpm’s way of saying “link this from the workspace, never from the registry”. One detail in the test script earned its quoting the hard way: node --test needs a real glob like "dist/**/*.test.js". Passing a bare directory such as dist/ makes Node treat it as an entry module, which resolves dist/index.js if one exists and reports a false pass with zero tests executed, or crashes with MODULE_NOT_FOUND if it does not.
Give the logger its compiler config:
vim packages/logger/tsconfig.json
It extends the shared base and only sets directories:
{
"extends": "@repo/config/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
Every other package uses an identical tsconfig.json, so copy it around now instead of retyping it four times:
for d in packages/db apps/api apps/worker; do cp packages/logger/tsconfig.json "$d/"; done
The logger itself is a dependency-free structured logger that writes one JSON line per event:
vim packages/logger/src/index.ts
Add:
export type LogLevel = "debug" | "info" | "warn" | "error";
const LEVELS: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
export interface Logger {
debug: (msg: string, fields?: Record<string, unknown>) => void;
info: (msg: string, fields?: Record<string, unknown>) => void;
warn: (msg: string, fields?: Record<string, unknown>) => void;
error: (msg: string, fields?: Record<string, unknown>) => void;
}
export function createLogger(
service: string,
minLevel: LogLevel = "info"
): Logger {
const write = (
level: LogLevel,
msg: string,
fields: Record<string, unknown> = {}
): void => {
if (LEVELS[level] < LEVELS[minLevel]) return;
const line = JSON.stringify({
ts: new Date().toISOString(),
level,
service,
msg,
...fields,
});
process.stdout.write(line + "\n");
};
return {
debug: (msg, fields) => write("debug", msg, fields),
info: (msg, fields) => write("info", msg, fields),
warn: (msg, fields) => write("warn", msg, fields),
error: (msg, fields) => write("error", msg, fields),
};
}
A small test file keeps it honest and gives the task graph something to run later:
vim packages/logger/src/index.test.ts
Two cases using the built-in Node test runner:
import test from "node:test";
import assert from "node:assert/strict";
import { createLogger } from "./index.js";
test("createLogger returns all four level methods", () => {
const logger = createLogger("test-service");
assert.equal(typeof logger.debug, "function");
assert.equal(typeof logger.info, "function");
assert.equal(typeof logger.warn, "function");
assert.equal(typeof logger.error, "function");
});
test("levels below minLevel do not throw and are suppressed", () => {
const logger = createLogger("test-service", "error");
assert.doesNotThrow(() => logger.debug("hidden"));
assert.doesNotThrow(() => logger.error("visible", { code: 500 }));
});
The db package follows the same shape. Its manifest:
vim packages/db/package.json
Identical to the logger’s apart from the name:
{
"name": "@repo/db",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"check-types": "tsc --noEmit -p tsconfig.json",
"test": "node --test \"dist/**/*.test.js\""
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/node": "^24.0.0",
"typescript": "^5.6.0"
}
}
The store implementation is an in-memory job queue with a repository-shaped interface, so swapping in PostgreSQL later touches nothing in the apps:
vim packages/db/src/index.ts
Add:
import { randomUUID } from "node:crypto";
export type JobStatus = "queued" | "processing" | "done";
export interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
status: JobStatus;
createdAt: string;
}
export interface QueueStats {
queued: number;
processing: number;
done: number;
total: number;
}
export class Db {
private jobs = new Map<string, Job>();
insertJob(type: string, payload: Record<string, unknown> = {}): Job {
const job: Job = {
id: randomUUID(),
type,
payload,
status: "queued",
createdAt: new Date().toISOString(),
};
this.jobs.set(job.id, job);
return job;
}
claimNextJob(): Job | undefined {
for (const job of this.jobs.values()) {
if (job.status === "queued") {
job.status = "processing";
return job;
}
}
return undefined;
}
completeJob(id: string): void {
const job = this.jobs.get(id);
if (!job) throw new Error(`job not found: ${id}`);
job.status = "done";
}
stats(): QueueStats {
const stats: QueueStats = { queued: 0, processing: 0, done: 0, total: 0 };
for (const job of this.jobs.values()) {
stats[job.status] += 1;
stats.total += 1;
}
return stats;
}
}
export function createDb(): Db {
return new Db();
}
And its test:
vim packages/db/src/index.test.ts
Cover the full lifecycle and the empty-queue edge:
import test from "node:test";
import assert from "node:assert/strict";
import { createDb } from "./index.js";
test("insert, claim, and complete a job", () => {
const db = createDb();
const job = db.insertJob("email.send", { to: "[email protected]" });
assert.equal(job.status, "queued");
const claimed = db.claimNextJob();
assert.ok(claimed);
assert.equal(claimed.id, job.id);
assert.equal(claimed.status, "processing");
db.completeJob(claimed.id);
assert.deepEqual(db.stats(), {
queued: 0,
processing: 0,
done: 1,
total: 1,
});
});
test("claimNextJob returns undefined on an empty queue", () => {
const db = createDb();
assert.equal(db.claimNextJob(), undefined);
});
That completes the shared layer.
Create the API and worker apps
The API is a Fastify service that queues jobs; the worker drains them. Both import the internal packages, which is exactly the dependency edge Turborepo will use for build ordering. The API manifest:
vim apps/api/package.json
Add:
{
"name": "api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"check-types": "tsc --noEmit -p tsconfig.json",
"test": "node --test \"dist/**/*.test.js\"",
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js"
},
"dependencies": {
"@repo/db": "workspace:*",
"@repo/logger": "workspace:*",
"fastify": "^5.0.0"
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/node": "^24.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
Split the app from the listener so tests can inject requests without binding a port:
vim apps/api/src/app.ts
Three routes: health, job creation, queue stats:
import Fastify, { type FastifyInstance } from "fastify";
import { createDb } from "@repo/db";
import { createLogger } from "@repo/logger";
const logger = createLogger("api");
interface CreateJobBody {
type: string;
payload?: Record<string, unknown>;
}
export function buildApp(): FastifyInstance {
const app = Fastify();
const db = createDb();
app.get("/healthz", async () => ({
status: "ok",
uptime: process.uptime(),
}));
app.post<{ Body: CreateJobBody }>("/jobs", async (request, reply) => {
const { type, payload } = request.body;
if (!type) {
reply.code(400);
return { error: "type is required" };
}
const job = db.insertJob(type, payload ?? {});
logger.info("job queued", { id: job.id, type: job.type });
reply.code(201);
return job;
});
app.get("/jobs/stats", async () => db.stats());
return app;
}
The entrypoint that actually listens:
vim apps/api/src/server.ts
Add:
import { createLogger } from "@repo/logger";
import { buildApp } from "./app.js";
const logger = createLogger("api");
const app = buildApp();
const port = Number(process.env.PORT ?? 3000);
app
.listen({ port, host: "0.0.0.0" })
.then(() => logger.info("api listening", { port }))
.catch((err) => {
logger.error("failed to start", { err: String(err) });
process.exit(1);
});
And the API’s tests, using Fastify’s inject to hit routes in-process:
vim apps/api/src/app.test.ts
Add:
import test from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "./app.js";
test("GET /healthz returns ok", async () => {
const app = buildApp();
const res = await app.inject({ method: "GET", url: "/healthz" });
assert.equal(res.statusCode, 200);
assert.equal(res.json().status, "ok");
await app.close();
});
test("POST /jobs queues a job and stats reflect it", async () => {
const app = buildApp();
const created = await app.inject({
method: "POST",
url: "/jobs",
payload: { type: "report.generate", payload: { month: "2026-07" } },
});
assert.equal(created.statusCode, 201);
assert.equal(created.json().status, "queued");
const stats = await app.inject({ method: "GET", url: "/jobs/stats" });
assert.equal(stats.json().queued, 1);
await app.close();
});
test("POST /jobs without a type is rejected", async () => {
const app = buildApp();
const res = await app.inject({ method: "POST", url: "/jobs", payload: {} });
assert.equal(res.statusCode, 400);
await app.close();
});
The worker is smaller. Its manifest:
vim apps/worker/package.json
Add:
{
"name": "worker",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"check-types": "tsc --noEmit -p tsconfig.json",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"@repo/db": "workspace:*",
"@repo/logger": "workspace:*"
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/node": "^24.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
And its loop, which seeds a few jobs and processes the queue until it drains:
vim apps/worker/src/index.ts
Add:
import { createDb } from "@repo/db";
import { createLogger } from "@repo/logger";
const logger = createLogger("worker");
const db = createDb();
for (const type of ["email.send", "report.generate", "cache.warm"]) {
db.insertJob(type);
}
logger.info("worker started", { pending: db.stats().queued });
const timer = setInterval(() => {
const job = db.claimNextJob();
if (!job) {
logger.info("queue drained, shutting down", { processed: db.stats().done });
clearInterval(timer);
return;
}
logger.info("processing job", { id: job.id, type: job.type });
db.completeJob(job.id);
}, 500);
All the code is in place. Nothing has been built yet, which is exactly where Turborepo enters.
Add Turborepo and author turbo.json
Install turbo as a dev dependency at the workspace root. The -w flag is required; pnpm otherwise refuses to add packages to the root of a workspace:
pnpm add turbo -Dw
pnpm links it and records the current release:
devDependencies:
+ turbo 2.10.4
Done in 670ms using pnpm v11.12.0
Now the file that drives everything. turbo.json declares each task Turborepo may run, what it depends on, and what it produces:
vim turbo.json
Add:
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "!**/*.md"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"]
},
"check-types": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
Line by line, the parts that matter:
tasksis the registry. Each key maps to a script name in the package manifests. This key was calledpipelinein the old 1.x line; using the old name is a hard error now, shown in the troubleshooting section."dependsOn": ["^build"]is the heart of the graph. The caret means “the build task of my workspace dependencies”, so the API’s build waits for@repo/loggerand@repo/db, while unrelated packages build in parallel.outputsnames what gets stored in the cache. On a cache hit Turborepo restores these paths byte for byte, so you can delete everydist/directory and get it back without running tsc. With a remote cache (covered in the next article in this series), teammates and CI get the same restore.inputscontrols hashing.$TURBO_DEFAULT$keeps the default of every git-tracked file in the package, and!**/*.mdexcludes docs, so editing a README no longer invalidates a build.testdepends onbuildwithout the caret: the same package’s build must finish first, because the tests run against compiled output indist/.devopts out of caching and is markedpersistent, since a watch process never exits and must not block the graph.
The full configuration surface is documented in the Turborepo configuration reference.
Run the first cached build
Install the workspace, then commit. The commit is not ceremony: Turborepo fingerprints task inputs with git, so an initialized repo makes hashing precise and enables change-based builds later:
pnpm install
git init -b main
git add -A
git commit -m "initial monorepo"
Now the first build. Everything is a cache miss, which is expected:
pnpm exec turbo run build
Every task reports a miss, executes, and gets stored:
• turbo 2.10.4
• Packages in scope: @repo/config, @repo/db, @repo/logger, api, worker
• Running build in 5 packages
• Remote caching disabled
@repo/logger:build: cache miss, executing d1afa3a9fcb7dd7b
@repo/db:build: cache miss, executing 67531775d586f87d
@repo/logger:build: $ tsc -p tsconfig.json
@repo/db:build: $ tsc -p tsconfig.json
worker:build: cache miss, executing cbcad5ed42e7ff75
api:build: cache miss, executing 070eae12935b9cef
worker:build: $ tsc -p tsconfig.json
api:build: $ tsc -p tsconfig.json
Tasks: 4 successful, 4 total
Cached: 0 cached, 4 total
Time: 3.682s
Read the ordering: logger and db start together because neither depends on the other, and api and worker only start once their dependencies finish. Five packages in scope, four tasks run, because @repo/config has no build script. Run the identical command again:
pnpm exec turbo run build
Nothing executes this time; the saved logs replay instead:
@repo/db:build: cache hit, replaying logs 67531775d586f87d
@repo/logger:build: cache hit, replaying logs d1afa3a9fcb7dd7b
api:build: cache hit, replaying logs 070eae12935b9cef
worker:build: cache hit, replaying logs cbcad5ed42e7ff75
Tasks: 4 successful, 4 total
Cached: 4 cached, 4 total
Time: 17ms >>> FULL TURBO
3.682 seconds down to 17 milliseconds. FULL TURBO is the banner for a run where every task came from cache, and it looks like this on the lab box:

The cache also survives deleted outputs; wipe every build directory and turbo restores them from .turbo/cache instead of recompiling:
rm -rf apps/api/dist apps/worker/dist packages/logger/dist packages/db/dist
pnpm exec turbo run build
The summary line tells the story:
Tasks: 4 successful, 4 total
Cached: 4 cached, 4 total
Time: 21ms >>> FULL TURBO
For an honest baseline, here is the same repo measured on the same VM with caching out of the picture:
| Scenario | What ran | Wall time |
|---|---|---|
pnpm -r run build (no Turborepo) | 4 sequentially ordered tsc builds, every time | 3.0s |
turbo run build, cold cache | 4 tsc builds, parallel where possible, plus hashing | 3.7s |
turbo run build, warm cache | 0 builds, 4 cache restores | 17 to 21ms |
The cold run costs slightly more than plain pnpm on a repo this small because hashing has overhead and four tsc processes barely benefit from parallelism. That inversion flips fast as package count grows, and every warm run afterwards is effectively free. This repo spends 3.7 seconds once, then never again until source actually changes.
Run tests through the task graph
Because test depends on build, one command compiles whatever is stale and then tests it:
pnpm exec turbo run test
The builds replay from cache and the three packages with test scripts run their suites:
@repo/logger:test: ✔ createLogger returns all four level methods (0.946943ms)
@repo/logger:test: ✔ levels below minLevel do not throw and are suppressed (2.262521ms)
@repo/db:test: ✔ insert, claim, and complete a job (2.333627ms)
@repo/db:test: ✔ claimNextJob returns undefined on an empty queue (0.213167ms)
api:test: ✔ GET /healthz returns ok (68.291682ms)
api:test: ✔ POST /jobs queues a job and stats reflect it (12.279439ms)
api:test: ✔ POST /jobs without a type is rejected (4.055582ms)
Tasks: 7 successful, 7 total
Cached: 4 cached, 7 total
Time: 4.431s
Seven tasks: four builds from cache, three test runs. Test results are cached exactly like builds, so re-running this command on unchanged code returns in milliseconds too. The apps themselves work; start the API and hit it:
node apps/api/dist/server.js &
curl -s http://localhost:3000/healthz
curl -s -X POST http://localhost:3000/jobs \
-H "Content-Type: application/json" \
-d '{"type":"email.send","payload":{"to":"[email protected]"}}'
Both endpoints answer with the queue doing its thing:
{"status":"ok","uptime":2.008897738}
{"id":"3be5a92a-b5b3-4946-93a9-346d8ba15384","type":"email.send","payload":{"to":"[email protected]"},"status":"queued","createdAt":"2026-07-12T21:34:39.293Z"}
Kill the background server with kill %1 when done poking at it.
Build only what changed
Scoping is where a monorepo stops feeling monolithic. --filter restricts the run to a package and, thanks to the graph, everything it needs:
pnpm exec turbo run build --filter=api
Scope shrinks to the API and its two internal dependencies:
• Packages in scope: api
• Running build in 1 packages
@repo/db:build: cache hit, replaying logs 67531775d586f87d
@repo/logger:build: cache hit, replaying logs d1afa3a9fcb7dd7b
api:build: cache hit, replaying logs 070eae12935b9cef
Tasks: 3 successful, 3 total
Cached: 3 cached, 3 total
Time: 11ms >>> FULL TURBO
The worker never enters the picture. --affected goes one step further and asks git which packages changed relative to the base branch, then builds only those and their dependents. Touch one line in the logger and watch the blast radius:
echo "// touched for affected demo" >> packages/logger/src/index.ts
pnpm exec turbo run build --affected
Three packages enter scope, and only one of the four tasks comes from cache:
• Packages in scope: @repo/logger, api, worker
• Running build in 3 packages
@repo/logger:build: cache miss, executing 3a9accdc68432c8f
@repo/db:build: cache hit, replaying logs a3b784ed4572c5c2
@repo/logger:build: $ tsc -p tsconfig.json
api:build: cache miss, executing d246cff1348f783d
worker:build: cache miss, executing 1dadd70e8bc129c4
worker:build: $ tsc -p tsconfig.json
api:build: $ tsc -p tsconfig.json
Tasks: 4 successful, 4 total
Cached: 1 cached, 4 total
Time: 3.343s
The logger changed, so it rebuilds, and api and worker rebuild because they consume it. The db package sits outside the change and replays from cache:

In CI on a pull request, this single flag is the difference between building 50 packages and building 3. One catch: --affected needs enough git history to reach the base branch, so on a shallow checkout (the GitHub Actions default is fetch-depth: 1) every package looks changed. Set fetch-depth: 0 or deep enough to include the merge base.
Inspect the workspace with turbo ls and turbo query
Two read-only commands help when the graph does something you did not expect. The quick one lists what Turborepo sees:
pnpm exec turbo ls
Five packages, with their paths:
• turbo 2.10.4
5 packages (pnpm9)
@repo/config packages/config
@repo/db packages/db
@repo/logger packages/logger
api apps/api
worker apps/worker
For anything deeper there is turbo query, a GraphQL interface over the repo’s structure. Package listings, dependency edges, affected sets, and task graphs are all queryable:
pnpm exec turbo query "query { packages { items { name path } } }"
It returns JSON you can feed to jq or a script:
{
"data": {
"packages": {
"items": [
{ "name": "//", "path": "" },
{ "name": "@repo/config", "path": "packages/config" },
{ "name": "@repo/db", "path": "packages/db" },
{ "name": "@repo/logger", "path": "packages/logger" },
{ "name": "api", "path": "apps/api" },
{ "name": "worker", "path": "apps/worker" }
]
}
}
}
The // entry is the workspace root, which Turborepo treats as a package of its own.
Rebuild on change with turbo watch
turbo watch keeps the graph live: it runs the task once, then re-runs the affected slice whenever a source file changes. Start it against build:
pnpm exec turbo watch build
Editing a file in the db package while it runs produced this, with the untouched logger explicitly kept as a cache hit:
@repo/logger:build: cache hit (outputs already on disk), replaying logs 843374c7cfd6d5f9
@repo/db:build: cache miss, executing bc4bd6591b611690
@repo/db:build: $ tsc -p tsconfig.json
worker:build: cache miss, executing 70e736e3e8bcf96b
api:build: cache miss, executing c8163493f8863850
worker:build: $ tsc -p tsconfig.json
api:build: $ tsc -p tsconfig.json
For interactive development the same idea applies to the dev task: pnpm exec turbo run dev starts both apps’ tsx watchers side by side, uncached and persistent, exactly as declared in turbo.json.
Troubleshooting errors from a real setup
Three failures came up while building this exact repo. All three are verbatim.
Error: “Found `pipeline` field instead of `tasks`”
Most Turborepo tutorials still floating around were written for the 1.x line, which ended support in June 2026, and they all show a pipeline key in turbo.json. Copy one of those configs into a current install and every turbo command dies immediately:
x Found `pipeline` field instead of `tasks`.
,-[turbo.json:3:15]
2 | "$schema": "https://turborepo.dev/schema.json",
3 | ,-> "pipeline": {
: `---- Rename `pipeline` field to `tasks`
`----
help: Changed in 2.0: `pipeline` has been renamed to `tasks`.
The fix is the rename the error suggests: the key is tasks now, with identical contents. If you are copying config from an older blog post, treat a pipeline key as the tell that everything else in that post needs checking too.
Error: “ERR_PNPM_IGNORED_BUILDS Ignored build scripts: esbuild”
On the first install, pnpm 11 refused to run esbuild’s postinstall script:
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: [email protected]
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
This is pnpm’s supply-chain protection doing its job; postinstall scripts are a common malware vector. The interactive fix is pnpm approve-builds, but for a repo other people will clone, commit the approval in pnpm-workspace.yaml instead, as done earlier with the allowBuilds block. One caution from testing: older guides put an onlyBuiltDependencies list in the pnpm field of package.json, and pnpm 11 ignores that location, printing a warning that the field moved. Only the workspace YAML works now.
Warning: “failed to get git status for dirty hash”
If the repo is owned by a different user than the one running turbo (common when files were rsynced to a server as root, or in some container setups), every run degrades with:
WARNING failed to get git status for dirty hash: Git error: fatal: detected dubious ownership in repository at '/root/turborepo-lab'
It looks harmless, but it is not: with git status unavailable, hashing loses precision and tasks that should be cache hits re-execute. In the lab this exact warning turned a would-be FULL TURBO run into four cache misses. Tell git the directory is trusted and the hits come back:
git config --global --add safe.directory ~/turborepo-lab
Re-run the build after that and the summary goes back to full cache hits.
Turborepo command reference
The commands this guide exercised, in the form you will actually type them:
| Command | What it does |
|---|---|
turbo run build | Build everything, cached, in graph order |
turbo run build --filter=api | Build one package plus its dependencies |
turbo run build --affected | Build only packages changed vs the base branch, plus dependents |
turbo run build --force | Ignore the cache and re-execute everything |
turbo run build --dry | Show what would run and why, without running it |
turbo watch build | Re-run the affected slice on every file change |
turbo ls | List the packages Turborepo detects |
turbo query | GraphQL queries against packages, tasks, and affected sets |
turbo prune api --docker | Extract a minimal subset of the repo for one app’s Docker build |
That last row is where this series goes next. The cache built here is still local to one machine; a follow-up guide self-hosts a Turborepo remote cache with Docker Compose and MinIO so every developer and CI runner shares the same hits, and a third covers turbo prune multi-stage Docker images for the api and worker apps built above.