Developer Tools

Moving Beyond Node: Configuring Bun and pnpm for Monorepos

By DexNox Dev Team Published May 28, 2026

Monorepos allow developers to consolidate application codebases and shared utility packages into a single repository. However, coordinating multiple projects introduces execution overhead, dependency bloat, and compilation bottlenecking. Pairing pnpm for strict dependency mapping with Bun for fast runtime execution and Turborepo for build orchestration yields a highly optimized development pipeline.

Content-Addressable Storage Architecture

pnpm uses a content-addressable storage model to manage dependencies. In a typical npm or yarn workspace layout, duplicate packages are downloaded and copied into the node_modules folders of individual workspace projects. If multiple applications depend on a library, the registry fetches and writes identical copies to disk multiple times.

pnpm prevents this by storing all package versions in a global content-addressable store. Inside each project, the node_modules directory contains hard links pointing back to the single global store location. This minimizes disk space consumption and increases dependency resolution performance.

On Unix-based filesystems (ext4, APFS), a hard link is an additional directory entry pointing directly to the filesystem inode, incurring zero storage duplication. On Windows NTFS filesystems, pnpm implements this using NTFS hard links, which update the Master File Table (MFT) record to link multiple paths to the same data clusters.

~/.local/share/pnpm/store/v3/
├── files/
│   ├── 00/
│   │   ├── 4a8f2c...  # react@19.1.0 - index.js
│   │   └── 7b1e09...  # react@19.1.0 - react.development.js
│   └── ...

project-root/
├── apps/
│   ├── web/
│   │   └── node_modules/
│   │       ├── .pnpm/
│   │       │   └── react@19.1.0/
│   │       │       └── node_modules/
│   │       │           └── react/
│   │       │               └── index.js  → [hard link → store/files/00/4a8f2c...]
│   │       └── react → .pnpm/react@19.1.0/node_modules/react  (symlink)

The nested layout inside the .pnpm directory prevents “phantom dependencies,” where a package can import another library that was hoisted to the root directory despite not declaring it in its local configuration.

Setting Up the Workspace Structure

To construct the monorepo, define the workspace boundaries in the root configurations. The directories are organized into apps/ for deployable service applications, packages/ for shared libraries, and tooling/ for configuration presets.

Workspace Configuration (pnpm-workspace.yaml)

packages:
  - "apps/*"
  - "packages/*"
  - "tooling/*"

Root Package Definition (package.json)

Configure the root package.json to manage monorepo-wide scripts and register the package manager:

{
  "name": "@acme/monorepo",
  "private": true,
  "packageManager": "pnpm@9.15.4",
  "scripts": {
    "build": "turbo run build",
    "build:web": "pnpm --filter @acme/web build",
    "build:api": "pnpm --filter @acme/api build",
    "dev": "turbo run dev --parallel",
    "dev:web": "pnpm --filter @acme/web dev",
    "test": "turbo run test",
    "test:unit": "pnpm --filter './packages/*' run test",
    "lint": "turbo run lint",
    "lint:fix": "pnpm --filter '*' run lint:fix",
    "typecheck": "turbo run typecheck",
    "clean": "turbo run clean && rm -rf node_modules",
    "format": "pnpm --filter '*' run format"
  },
  "devDependencies": {
    "turbo": "^2.5.0"
  }
}

Task Pipeline Specification (turbo.json)

Configure Turborepo pipelines to run build and validation tasks in the correct topological order based on the dependency graph:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "clean": {
      "cache": false
    }
  }
}

Package Manager Constraints (.npmrc)

Configure the .npmrc file to enforce strict dependency boundaries and specify peer dependency resolutions:

strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist=false
link-workspace-packages=true
prefer-workspace-packages=true
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

Shared TypeScript Configurations

Rather than duplicating configuration parameters across multiple workspace directories, create a base configuration inside tooling/ and extend it.

Base Configuration (tooling/tsconfig/tsconfig.base.json)

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "composite": true,
    "incremental": true
  },
  "exclude": ["node_modules", "dist", "build", "coverage", ".turbo"]
}

Local Extending Template (apps/web/tsconfig.json)

Extend the base configuration in target workspaces. Reference the custom configuration package using the workspace protocol:

{
  "extends": "@acme/config/tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}

Configuring Bun for Execution

Configure Bun with a bunfig.toml at the repository root to control script execution parameters, registry URLs, and testing outputs:

[install]
peer = true
production = false
optional = true
exact = true
globalDir = "~/.bun/install/global"
globalBinDir = "~/.bun/bin"

[install.scopes]
"@acme" = { url = "https://npm.pkg.github.com", token = "$GITHUB_TOKEN" }

[install.cache]
dir = "~/.bun/install/cache"
disable = false

[test]
coverage = true
coverageDir = "./coverage"
coverageReporter = ["text", "lcov"]
coverageThreshold = { line = 80, function = 75, statement = 80 }
timeout = 30000
preload = ["./tooling/test-setup.ts"]

[run]
shell = "bun"

[install.registry]
url = "https://registry.npmjs.org/"

Caching and Build Orchestration Mechanics

Turborepo handles task execution using a hashing engine. When a task is executed (e.g., turbo run build), Turborepo computes a hash signature based on:

  1. The contents of local files matching the package inputs (excluding paths ignored in .gitignore).
  2. Global configurations listed in turbo.json.
  3. The dependency tree of the package.
  4. Active environment variables specified in the task definition.

If the hash matches a record stored in the local .turbo/cache directory or the remote cache server, Turborepo skips execution entirely and replays the stored stdout, stderr, and output directory artifacts (e.g., dist/). This shortens build loops on developers’ workstations and CI runners.

Files + Configs + Env Vars ──▶ SHA-256 Hash Generation ──▶ Cache Lookup

    ┌─────────────────────────── Cache Hit ───────────────────┴─── Cache Miss ───┐
    ▼                                                                            ▼
Restore saved outputs directly                                           Execute build task

Production Docker Deployment Blueprint

In production monorepo environments, Docker build layers must be cached carefully. Running pnpm install across the entire workspace in a single container step pulls unnecessary dependencies and invalidates caches. We use the pnpm deploy command to extract a single application workspace and its dependency subset into a clean release stage.

Below is a complete, production-ready Dockerfile for deploying the @acme/web application within our monorepo structure:

# Stage 1: Base image pinning Node and pnpm
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app

# Stage 2: Pruning the monorepo workspace files
FROM base AS builder
COPY . .
RUN npx turbo prune @acme/web --out=out

# Stage 3: Installing dependencies on pruned source
FROM base AS installer
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# Stage 4: Compiling the source files
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN pnpm turbo run build --filter=@acme/web...

# Stage 5: Final execution runner stage
FROM alpine:3.20 AS runner
WORKDIR /app
RUN apk add --no-cache nodejs
COPY --from=installer /app/apps/web/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

Performance Benchmarks

The following metrics represent a monorepo setup containing 3 deployable applications (web, api, and worker) and 5 internal packages. The test suite was evaluated on a clean machine using Node v22.1.0 and Bun v1.1.42.

Operation Metricnpm v10.9pnpm v9.15Bun v1.1.42
Cold Install (empty cache)38.4 s12.1 s4.2 s
Warm Install (local cache)14.7 s3.8 s1.1 s
Disk Space Allocated1.34 GB412 MB298 MB
Lockfile Parse Latency820 ms140 ms28 ms
Dependency Resolution Time6.2 s2.1 s0.8 s

What Breaks in Production

Phantom Dependency Resolution Crashes

When migrating a legacy repository to pnpm, runtime imports may fail because components import modules that are not listed in the local package definition. These modules worked in the old package manager due to flat hoisting behavior that made transitive dependencies accessible.

Resolve this by checking the dependency tree using the command pnpm why <package-name> inside the failing module directory and adding the missing package explicitly to its configuration file.

Cache Invalidations on Missing Environment Variables

Turborepo relies on matching environment configurations to check if a task’s output can be resolved from the cache. If an application utilizes variables that are not declared inside the pipeline configuration, Turborepo will retrieve stale cache objects, causing build failures.

Ensure all variables that affect build output are declared inside the globalDependencies or local tasks lists of turbo.json.

TypeScript Composite Circular References

Enabling incremental build features with composite modes requires mapping references between internal packages. If package A references package B, and package B references package A, the TypeScript compiler will fail during reference compilation.

Avoid circular dependency structures. Refactor shared models or types into a third utility package that is imported by both systems.

Lockfile Desynchronization in CI

If a developer adds a package locally using npm or Bun without updating the canonical lockfile, the CI build step will fail or download mismatched versions.

Enforce the --frozen-lockfile flag in pnpm or the --no-save flag in Bun during CI execution to abort execution if local modifications are not reflected in the lockfile.

Frequently Asked Questions

How do pnpm workspaces differ from standard yarn workspaces?

pnpm workspaces utilize a content-addressable storage vault to link dependency files via system hard links. Yarn and npm write full duplicate packages or rely on directory hoisting patterns that expose packages to phantom dependency imports.

Why does Bun execute test files faster than Jest or Vitest?

Bun executes tests using a built-in test runner implemented in Zig. This architecture avoids loading heavy JavaScript compilation frameworks and parses modules directly, resulting in lower test bootstrap times.

When should I use pnpm-lock.yaml instead of bun.lockb?

Use the pnpm configuration lockfile as the primary source of truth when using pnpm for package resolution and install steps. The binary lockfile from Bun is only required when package installation tasks are coordinated entirely by Bun.

Frequently Asked Questions

Can I use pnpm for dependency resolution and Bun for running scripts in the same monorepo?

Yes, you can run both utilities in tandem within the same monorepo project. pnpm coordinates the dependency graph and node_modules layout while Bun handles script execution, code transpilation, and testing tasks.

How do I read the binary bun.lockb file during code review or CI debugging?

Execute the bun lockfile inspection command to print the lockfile representation to standard output. Teams that run Bun alongside pnpm often prioritize pnpm-lock.yaml as the source of truth for dependencies.

What happens when I publish a package that uses workspace:* in its dependencies?

pnpm translates the workspace token into the exact semantic version declared in the target package's configuration during publication. This ensures that the published package lists a valid version in its dependencies.

Why should I keep pnpm instead of switching entirely to Bun for a monorepo?

pnpm provides strict workspace dependency isolation and prevents packages from importing unlisted dependencies. It also integrates with a wider range of monorepo tooling like Turborepo and Changesets.

Do I need both .npmrc and bunfig.toml in the same repository?

Yes, both files are required because they configure distinct tools in the workspace. pnpm reads the npmrc file for package retrieval parameters, and Bun reads bunfig.toml to resolve runtime settings.