Move a NestJS backend to a pnpm and Turborepo monorepo
Share TypeScript contracts across APIs with pnpm workspaces, Turborepo task graphs, cache-friendly Dockerfiles, and pnpm catalogs—without turning common into a junk drawer.
This article is published in English.
Sources: Why I Chose a Monorepo for My Backend (And What Actually Happened)
Roughly nine months into a growing NestJS backend, a single API, one Dockerfile, and one pipeline stopped being enough. A second API was coming, then a third, plus a React admin UI. The open question was how to split ownership without losing shared TypeScript contracts.
The path taken was a pnpm workspace monorepo orchestrated with Turborepo: what worked, what hurt, and what would change on a second attempt—engineering choices only, no product branding.
The Pain That Started It
The first service began as one NestJS app covering authentication, content management, uploads to Cloudflare R2, and Meilisearch-backed search. That shape held until search needed its own deploy cadence and failure boundary while still sharing enums and helpers with the primary API.
Two options appeared: copy shared bits into another repository and keep them synchronized by hand, or share code in-place while letting each app build and ship alone.
Duplicate enums across repos look harmless until one side updates and the other does not. A near-miss on a content-type enum was enough to reject multi-repo sharing at this scale.
Why pnpm, Why Turborepo
Three stacks were compared: Nx, Yarn workspaces with Lerna, and pnpm workspaces with Turborepo.
Nx was capable but felt like a second framework: steep learning curve and heavier generated scaffolding than four apps and a few shared packages required. Graph visualizers and affected-command machinery were more than the problem needed.
Yarn workspaces plus Lerna looked familiar on Yarn 1.x, yet Lerna’s maintenance story was a concern and Yarn’s loose resolution had already allowed phantom dependencies—imports that worked only because a transitive package happened to be present locally and then failed in production.
pnpm won on strict isolation: every import must be declared in package.json. During migration it immediately flagged @nestjs/mapped-types as used but undeclared, something Yarn had allowed through the graph. Combined with workspace linking without awkward manual links, that strictness was decisive.
Turborepo orchestrates builds on top. Declaring "dependsOn": ["^build"] makes shared packages compile before consumers, caches outputs, and parallelizes independent work. Most of the policy lives in one turbo.json:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
The ^build token means “build dependencies first.” Turborepo reads the package.json graph for the rest.
That thin config is intentional. The migration goal was faster feedback for a small service graph, not a platform team’s full affected-task toolkit. Keeping orchestration declarative made onboarding cheaper when the third API arrived: new apps copied turbo.json task names and joined the same cache keys.
What I Actually Share
Early on there were separate packages for types and utilities. Within months they merged into one @myapp/common package. The split looked tidy on paper and annoying in practice: every enum needed a home, and most files imported both packages anyway.
The shared tree now looks like:
packages/common/src/
├── enums/
│ ├── subdirectory-path.enum.ts # R2 bucket paths
│ ├── content.enum.ts # Content field types
│ └── index.ts
├── types/
│ ├── pagination.types.ts
│ └── index.ts
└── index.ts
Keep it small on purpose. Share only cross-service types, enums referenced by multiple apps, and a single UUID helper. Everything else stays app-local. The package is a contract, not a junk drawer.
Three more shared packages earned their place later:
A config package loads secrets. Both APIs fetch from the secrets manager at boot, so loadSecrets() lives once and runs from each main.ts before other startup work.
A telemetry package wraps OpenTelemetry tracing and structured JSON logging, including filtering noisy health-check spans. Cross-cutting observability is where shared code removes real drift between services.
A vitest-config package exports a shared Vitest factory so test setup stays consistent without copying configs.
The Dockerfile Problem
Monorepo Docker builds are the part tutorials underplay. The build context is the repository root. Each app has its own Dockerfile yet must reach shared packages. Cache-friendly COPY ordering is the main lever.
The pattern that stuck:
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.12.4 --activate
WORKDIR /app
# 1. Copy workspace manifests first (cache the install layer)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc turbo.json ./
COPY packages/common/package.json ./packages/common/
COPY packages/config/package.json ./packages/config/
COPY packages/telemetry/package.json ./packages/telemetry/
COPY apps/core/package.json ./apps/core/
RUN pnpm install --frozen-lockfile
# 2. Copy source and build in dependency order
COPY packages/common ./packages/common
COPY packages/config ./packages/config
COPY packages/telemetry ./packages/telemetry
COPY apps/core ./apps/core
RUN pnpm --filter @myapp/common build
RUN pnpm --filter @myapp/config build
RUN pnpm --filter @myapp/telemetry build
RUN pnpm --filter @myapp/core build:prod
# 3. Deploy step creates a self-contained directory
RUN pnpm deploy --filter @myapp/core --prod --legacy /deploy/core
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /deploy/core ./
CMD ["node", "dist/main"]
Copy every package.json first, install, then copy sources. Docker then reuses the install layer until dependencies change, not on every source edit.
The tax: adding a shared package means updating COPY lines in every consuming Dockerfile. A checklist helps, because a missing line yields opaque build failures. At the end, pnpm deploy materializes a standalone production tree with workspace packages resolved to real copies, so the final image needs neither pnpm nor the full monorepo layout.
Version Management with Catalogs
Version drift is the quiet monorepo tax: NestJS 11.0 in one app and 11.1 in another invites subtle one-service bugs.
pnpm Catalogs define shared ranges once in pnpm-workspace.yaml:
catalogs:
nestjs:
'@nestjs/common': ^11.1.28
'@nestjs/core': ^11.1.28
'@nestjs/platform-fastify': ^11.1.28
testing:
vitest: ^3.2.6
'@vitest/coverage-v8': ^2.1.0
Apps then reference the catalog entry:
{
"dependencies": {
"@nestjs/common": "catalog:nestjs"
},
"devDependencies": {
"vitest": "catalog:testing"
}
}
Bumping NestJS everywhere becomes one workspace edit plus pnpm install. Catalog only dependencies shared by two or more apps; a Mongoose version used by a single service can stay local until it crosses a boundary.
Catalogs also change how upgrades are reviewed. A NestJS bump becomes a workspace-level diff instead of a scatter of package files, which makes code review focus on release notes and breaking changes rather than on whether every app was updated consistently.
What I Got Wrong
Splitting types and utils was premature architecture. Merging later meant sweeping import updates that a single package would have avoided.
CI was over-fitted at first: separate GitHub Actions workflows per app with path filters so search changes would not build the core API. Shared-package changes must fan out to every consumer, and the filters cost more iteration than they saved. A single PR workflow that builds everything, accelerated by Turborepo cache, proved simpler.
What I Got Right
Strict resolution caught a real undeclared dependency during migration—the kind of footgun that otherwise surfaces months later.
Minimal shared packages stayed minimal. The infrastructure makes sharing easy; discipline keeps app-specific code out of common. A few hundred lines of real shared code is a feature.
Painful Dockerfile setup paid off: each service builds alone, pulls only the workspace packages it needs, and ships a small production image. By the time a third API and an admin dashboard landed beside six shared packages, adding another service was mostly repeating established templates for Docker, CI, and telemetry.
Skip the monorepo for one service with no second service planned. When a second service is imminent and types are already being copied between repos, the move is usually overdue. The migration cost is front-loaded; the payoff shows up the first time a shared enum changes and every consumer picks it up in one commit.
A monorepo does not invent good architecture. It makes good patterns cheaper to repeat and bad ones harder to hide.
Operationally, the biggest lasting win was predictability under growth. Shared enums stopped drifting. Telemetry fields lined up across APIs. Docker images stayed small because pnpm deploy discarded the workspace scaffolding. The costs that remained—Dockerfile COPY churn and the temptation to overshare—were manageable with checklists and package boundaries, not with another repository topology change.
If a future rewrite started today, the same stack would still be the default for this size of system: pnpm for strict installs, Turborepo for task graphs, catalogs for shared versions, and a deliberately small common package. The change would be starting with one shared package instead of two, and starting with one CI workflow instead of many.