Monorepo with Turborepo: Managing Four Apps in One Repository
I explain setting up pnpm workspaces + Turborepo using a real-world repository — 4 apps, 4 packages, uncompiled packages, an outputs bug that silently breaks caching, and containerizing a single app with Docker.

Most articles about monorepos start in an empty directory and end with "and that's how easy it is." That's not where the hard part is: whether to build shared packages or not, how you'll actually know if caching is working, and how to containerize a single app with Docker.
This post is based on a production repository — the very repo hosting the site you're reading right now. It contains four apps and four shared packages, and one of them isn't even Next.js.
First off: do you even need a monorepo?
Tip: When it's necessary
If multiple apps share the same code and that code needs to change across both in a single commit, a monorepo is the right choice. Design tokens, shared components, common type definitions, shared lint rules.
Warning: When it's not necessary
If the apps are independent, a monorepo only adds complexity: longer CI builds, more complex deployments, and git histories tangled together. Setting up a monorepo on the assumption that "we might share code later" means paying the cost before you ever have the need.
In my case, the sharing was real: czay.dev and fit.czay.dev share the exact same design tokens. Updating a color across two separate repos separately made version drift inevitable.
What does the repository look like?
czay.dev/
├── apps/
│ ├── web/ → czay.dev (Next.js)
│ ├── student/ → student.czay.dev (Next.js + Drizzle + Postgres)
│ ├── fit/ → fit.czay.dev (Next.js)
│ └── links/ → links.czay.dev (Astro)
├── packages/
│ ├── ui/ → @czay-dev/ui (tokens + shared components)
│ ├── mdx/ → @czay-dev/mdx (MDX options, slug generation)
│ ├── tsconfig/ → @czay-dev/tsconfig (shared TS config)
│ └── eslint-config/→ @czay-dev/eslint-config
├── pnpm-workspace.yaml
├── turbo.json
└── package.jsonThe dependency graph is straightforward: web → ui + mdx, fit → ui, while student and links don't use any shared packages. This is crucial because all of Turborepo's speed comes from understanding this graph — if ui hasn't changed, it doesn't need to rebuild fit.
Having links use Astro is an intentional choice: Turborepo doesn't care what framework you use. It only manages the task graph and cache; each app handles building using its own tooling.
Setting up pnpm workspace
Two files are all you need.
Example: pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"Example: App's package.json
{
"name": "web",
"dependencies": {
"@czay-dev/ui": "workspace:*",
"@czay-dev/mdx": "workspace:*"
}
}The workspace:* protocol is key: instead of downloading this dependency from npm, pnpm creates a symlink to the directory in the repository. When you switch to a published package, the protocol is automatically converted to an actual version number, but if you're not publishing, you don't even need to think about it.
Don't build packages (most of the time)
This is the choice that wastes the most time in monorepo setups. There are two approaches:
Compiled package: The package runs its own build step to output dist/, and applications consume the built output. Advantage: the package can be published to npm. Cost: you have to rebuild the package on every change, you need watch processes set up during dev, and source maps get messy.
JIT package (just-in-time): The package exports TypeScript source directly, delegating the build step to the app consuming it.
Example: packages/ui/package.json
{
"name": "@czay-dev/ui",
"exports": {
"./styles/globals.css": "./src/styles/globals.css",
"./components/button": "./src/components/button.tsx",
"./lib/utils": "./src/lib/utils.ts"
}
}Notice: no dist/, no main, no build script. It points directly to the .tsx file.
In return, you need to tell your application's bundler: "transpile this package too":
Example: apps/web/next.config.ts
const nextConfig: NextConfig = {
transpilePackages: ["@czay-dev/ui", "@czay-dev/mdx"],
};The payoff of this choice comes through in dev: when you modify a file in a package, fast refresh in your app reacts instantly — there's no build step sitting in between. Unless you're publishing the package to npm, JIT is almost always the right choice.
turbo.json
Example: turbo.json
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["DB_POSTGRES_URL", "RESEND_API_KEY", "NEXT_PUBLIC_*"]
},
"lint": { "dependsOn": ["^lint"] },
"typecheck": { "dependsOn": ["^typecheck"] },
"dev": { "cache": false, "persistent": true }
}
}Four details:
dependsOn: ["^build"] — The leading ^ means "the same task on my dependencies." Before building web, ui and mdx must be built. For JIT packages, this task is a no-op (the packages have no build script), but declaring the graph correctly means you won't have to change anything if a package needs a build step later.
dev: { cache: false, persistent: true } — The dev server is a long-running task that never finishes. persistent tells Turborepo this, while cache: false prevents caching its output. If you omit this, Turbo hangs waiting for the task to exit.
env allowlist — This is the source of the subtle bugs. Turborepo generates cache keys from inputs; if an environment variable isn't in this list, it's not included in the hash. So when you update NEXT_PUBLIC_API_URL and rebuild, Turbo says "inputs haven't changed" and returns the cached output, deploying your app with the old URL. Every variable that affects the build must be on this list.
outputs — And here is where I share my own mistake.
Caution: The cache that silently broke
For a long time, my outputs looked like this: [".next/**", "!.next/cache/**"]. Since three of the four apps were Next.js, this looked right. But links is an Astro app, and it writes its build output to dist/.
The result: Turbo cached the links build task, but couldn't find any files to save. On the next run, it reported a "cache hit", replayed the build logs, and dist/ was completely missing. No error messages—just missing files.
The fix: [".next/**", "!.next/cache/**", "dist/**"]
The takeaway rule here: caching can look like it's working when it's not. When you cache a task, delete the output folder, run turbo run build, and visually confirm that the files actually reappear.
Daily usage
pnpm dev # all apps at once
pnpm build # in order according to dependency graph
pnpm typecheck # tsc --noEmit in every package
turbo run dev --filter=web # only czay.dev
turbo run build --filter=web... # web and its dependencies
turbo run build --filter=...ui # ui and everything dependent on itThe dots in the --filter syntax specify direction: web... means "web and its dependencies", while ...ui means "ui and everything depending on it". The latter is the fastest way to see what might break when you edit a package.
Ports are assigned to avoid conflicts: web on 3000, fit on 3002, student on 3003.
Containerizing a single app from a monorepo with Docker
This is where people struggle the most with monorepos. I deploy the student app to my own server with Dokploy; the Docker image is built from the repo root because pnpm-lock.yaml and shared packages live there.
1. Standalone output and the correct root in Next.js
Example: apps/student/next.config.ts
import path from "node:path";
const nextConfig = {
output: "standalone" as const,
outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
};output: "standalone" configures Next.js to trace only the node_modules actually used and bundle them into a standalone executable directory.
outputFileTracingRoot is monorepo-specific and if you skip it, your image will crash at runtime: pnpm hoists dependencies to node_modules at the repo root, while Next.js defaults to tracing only inside the app directory, missing those hoisted packages. Pointing it to the root tells the file tracer to look further up.
2. Install only the required packages
Example: Dockerfile — dependency layer
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /repo
# Copy manifest files first: when source code changes, this layer
# comes from cache, so pnpm install doesn't re-run.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/student/package.json apps/student/
COPY packages/ui/package.json packages/ui/
COPY packages/mdx/package.json packages/mdx/
COPY packages/tsconfig/package.json packages/tsconfig/
COPY packages/eslint-config/package.json packages/eslint-config/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter student...There are two tricks here:
- Copy manifests first. Docker's layer cache checks file modifications; if you copy source code before
pnpm install, dependencies get reinstalled on every code change. This separation drops build times from minutes to seconds. --filter student...— Onlystudentand its dependencies are installed. Dependencies forfitandwebnever enter the image.
3. Copy only what's needed to the runner image
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/static ./apps/student/.next/static
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/public ./apps/student/public
USER nextjsIn the runner image, there is no pnpm, no source tree, and no other apps — just Node and the standalone build output.
Explanation: The bug that only caught me in production
The public/ folder was empty, and git does not track empty folders. Everything worked locally because the folder existed on my machine, but on a fresh clone, the folder didn't exist at all, causing the runtime COPY step to crash with "not found". The fix was adding a single line to the build step:
RUN mkdir -p apps/student/publicThese kinds of bugs are common in monorepos: discrepancies between local machine state and a fresh clone show up in far more places than in single-app repositories.
Summary
Summary
- Set up a monorepo only if you have shared code; don't set it up "just in case"
- Link packages with
workspace:*and don't build them — JIT packages +transpilePackagesgive you a much faster feedback loop - If you miss entries in
turbo.json'senvallowlist, stale cache will be served and your app deploys with outdated environment variables - If
outputsis missing paths, caching silently returns empty results — delete the output directory and verify - Build Docker images from the root, copy manifests first, and install only what's needed using
--filter app... - Set
outputFileTracingRootto the repo root in Next.js standalone builds, or the runtime image will crash
The true value of Turborepo isn't just speed—it's accurately understanding the graph. Once you define which app depends on which package, it decides what needs to be built and what comes from cache. If you define it incorrectly—like my outputs bug—it silently gives you the wrong answer, fast.

