← Back to DevBytes

Lerna: Complete Configuration Guide

Introduction to Lerna

Lerna is a powerful build and orchestration tool designed specifically for JavaScript and TypeScript monorepos. It streamlines the management of multiple packages within a single repository, handling everything from dependency management to versioning and publishing. Whether you're maintaining a large open-source project with dozens of packages or a private enterprise codebase, Lerna provides the structure and automation needed to keep your development workflow efficient and predictable.

What is Lerna and Why It Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

At its core, Lerna is a tool that splits your monolithic codebase into manageable, independently versioned packages while keeping them in a single Git repository. It addresses several critical challenges in monorepo development:

Lerna matters because manual monorepo management at scale becomes unsustainable. Without it, teams face circular dependency nightmares, inconsistent versions, duplicated devDependencies, and painfully slow installs. Lerna, often paired with npm workspaces or Yarn workspaces, provides a battle-tested solution that scales from small projects to massive repositories like Babel, Jest, and Create React App.

Getting Started with Lerna

Before diving into configuration details, let's set up a basic Lerna project. You can initialize Lerna in an existing repository or create a fresh one.

# Initialize a new Lerna monorepo
npx lerna init

# Or with specific options
npx lerna init --packages="packages/*" --independent

This command creates the following structure:

my-monorepo/
├── packages/           # Directory for your packages
├── lerna.json          # Main Lerna configuration file
├── package.json        # Root package.json
└── .gitignore

The lerna.json file is the heart of your configuration. Let's explore every aspect of it in detail.

Core Configuration: lerna.json

The lerna.json file defines how Lerna behaves across all packages. Here is a comprehensive breakdown of every configuration option available.

version

This is the most fundamental setting. It determines whether all packages share a single version (fixed/locked mode) or maintain independent versions.

{
  "version": "1.0.0"
}

With a fixed version like "1.0.0", all packages are published with the exact same version number. When you run lerna version, Lerna bumps this single version and applies it to every package. This is ideal for tightly coupled packages like a UI component library where components are always released together.

{
  "version": "independent"
}

With "independent", each package manages its own version in its local package.json. When you run lerna version, Lerna prompts you to choose version bumps for each changed package individually. This is perfect for loosely coupled packages like a collection of utilities, plugins, or microservices that evolve at different paces.

packages

Defines glob patterns for where Lerna should look for packages. The default is ["packages/*"], but complex monorepos often need multiple patterns.

{
  "packages": [
    "packages/*",
    "tools/*",
    "plugins/*",
    "!private-packages/*"
  ]
}

You can use negation patterns with ! to exclude directories. Lerna uses globby under the hood, so all standard glob patterns work.

npmClient

Specifies which package manager Lerna should use for installing dependencies and running scripts. The default is "npm".

{
  "npmClient": "yarn"
}

Supported values include "npm", "yarn", "pnpm", and as of newer versions, you can even specify exact versions:

{
  "npmClient": "yarn",
  "npmClientArgs": ["--production", "--frozen-lockfile"]
}

The npmClientArgs array passes additional arguments to the package manager during installs.

useWorkspaces

When set to true, Lerna delegates package management to the underlying workspace implementation of your chosen package manager (npm workspaces, Yarn workspaces, or pnpm workspaces). This is now the recommended approach.

{
  "useWorkspaces": true
}

When enabled, you must also define the workspaces field in your root package.json:

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "tools/*"
  ]
}

With useWorkspaces: true, Lerna no longer handles bootstrapping or symlinking itself—it relies entirely on the package manager's workspace functionality for that. This dramatically improves install performance and eliminates the deprecated lerna bootstrap command.

command

The command object allows you to configure behavior for specific Lerna commands. This is where much of the fine-grained control lives.

{
  "command": {
    "publish": {
      "ignoreChanges": ["*.md", "**/*.test.js"],
      "message": "chore(release): publish %s",
      "registry": "https://custom-registry.example.com"
    },
    "version": {
      "allowBranch": ["main", "release/*"],
      "conventionalCommits": true,
      "exact": true
    },
    "bootstrap": {
      "npmClientArgs": ["--no-optional"]
    }
  }
}

Each sub-object maps to a Lerna command name. Let's explore the most important command configurations.

command.publish Configuration

The publish command has the richest set of options, controlling how packages are released to registries.

{
  "command": {
    "publish": {
      "ignoreChanges": [
        "**/__tests__/**",
        "**/*.md",
        "**/*.spec.ts"
      ],
      "message": "chore(release): publish %v",
      "registry": "https://registry.npmjs.org",
      "verifyAccess": true,
      "verifyConditions": true,
      "allowBranch": ["main", "master", "release/*"],
      "includePrivate": false,
      "includeMergedTags": true,
      "tagVersionPrefix": "v",
      "otp": "123456"
    }
  }
}

command.version Configuration

The version command determines how Lerna calculates and applies version bumps.

{
  "command": {
    "version": {
      "allowBranch": ["main", "release/*"],
      "conventionalCommits": true,
      "exact": true,
      "includeMergedTags": true,
      "message": "chore(release): bump versions to %v",
      "noCommitHooks": false,
      "push": true,
      "sign": false,
      "yes": false,
      "preid": "beta",
      "changelog": {
        "types": {
          "feat": { "section": "Features" },
          "fix": { "section": "Bug Fixes" },
          "perf": { "section": "Performance Improvements" },
          "docs": { "section": "Documentation" }
        }
      }
    }
  }
}

command.bootstrap Configuration (Legacy)

Note: lerna bootstrap is deprecated when using workspaces. If you're not using workspaces, these options still apply.

{
  "command": {
    "bootstrap": {
      "npmClientArgs": ["--no-optional", "--production"],
      "ignore": ["package-*"],
      "scope": ["@myorg/utils"],
      "forceLocal": false,
      "hoist": true
    }
  }
}

command.run and command.exec Configuration

These commands execute scripts across packages. lerna run executes a script defined in each package's scripts section, while lerna exec runs arbitrary shell commands.

{
  "command": {
    "run": {
      "npmClient": "yarn",
      "stream": true,
      "parallel": true,
      "concurrency": 4,
      "ignore": ["package-*"],
      "scope": ["@myorg/*"],
      "noBail": false,
      "bail": true,
      "prefix": true
    },
    "exec": {
      "stream": true,
      "parallel": false,
      "concurrency": 2,
      "bail": true
    }
  }
}

command.add Configuration

The lerna add command adds dependencies to matched packages.

{
  "command": {
    "add": {
      "dev": false,
      "exact": false,
      "peer": false,
      "registry": "https://custom-registry.example.com"
    }
  }
}

command.import Configuration

The lerna import command imports external packages into the monorepo, preserving Git history.

{
  "command": {
    "import": {
      "flatten": true,
      "dest": "packages",
      "preserveCommit": true
    }
  }
}

Package Management Modes

Lerna supports two distinct modes for managing your monorepo's dependencies: the legacy bootstrap mode and the modern workspaces mode. Understanding the difference is crucial for configuration.

Legacy Bootstrap Mode (Deprecated)

In the original Lerna model, lerna bootstrap handled all dependency installation and cross-package linking. This mode is characterized by:

{
  "useWorkspaces": false,
  "npmClient": "npm"
}

With this configuration, you run lerna bootstrap after npm install at the root. Lerna manually creates symlinks between packages and installs their dependencies. This mode is slower and duplicates dependencies across packages.

Workspaces Mode (Recommended)

Modern Lerna leverages native package manager workspaces:

// lerna.json
{
  "useWorkspaces": true,
  "npmClient": "yarn"
}

// root package.json
{
  "private": true,
  "workspaces": ["packages/*"]
}

With workspaces, a single yarn install (or npm install / pnpm install) at the root handles everything: hoisting, symlinking, and installing all packages. Lerna no longer needs to bootstrap. The lerna bootstrap command is effectively replaced by your package manager's install command.

Working with package.json at the Root

The root package.json works in tandem with lerna.json. Several fields here directly affect Lerna's behavior.

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "tools/*",
    "!private-packages/*"
  ],
  "scripts": {
    "build": "lerna run build --stream",
    "test": "lerna run test --parallel",
    "lint": "lerna run lint --concurrency=4",
    "release": "lerna version --conventional-commits && lerna publish",
    "clean": "lerna clean --yes && rimraf node_modules"
  },
  "devDependencies": {
    "lerna": "^8.0.0",
    "typescript": "^5.3.0",
    "jest": "^29.0.0"
  }
}

Key points about the root package.json:

Advanced Configuration Techniques

Using Environment Variables in lerna.json

Lerna supports environment variable interpolation in lerna.json. This is invaluable for CI/CD pipelines where values change per environment.

{
  "command": {
    "publish": {
      "registry": "${NPM_REGISTRY_URL}",
      "otp": "${NPM_OTP}"
    }
  }
}

You can reference any environment variable using ${VARIABLE_NAME} syntax. Lerna resolves these at runtime.

Programmatic Configuration with lerna.json Extensions

For complex scenarios, you can use a JavaScript configuration file. Create a lerna.json that points to a JS module:

// lerna.js (CommonJS)
const fs = require('fs');
const path = require('path');

module.exports = {
  version: 'independent',
  packages: ['packages/*'],
  command: {
    publish: {
      ignoreChanges: getIgnoredPatterns(),
      message: getCommitMessage()
    }
  }
};

function getIgnoredPatterns() {
  // Dynamically read ignore patterns from a file
  const ignoreFile = path.join(__dirname, '.lernaignore');
  if (fs.existsSync(ignoreFile)) {
    return fs.readFileSync(ignoreFile, 'utf8').split('\n').filter(Boolean);
  }
  return ['**/*.md'];
}

function getCommitMessage() {
  const date = new Date().toISOString().split('T')[0];
  return `chore(release): publish on ${date}`;
}

Then reference it:

// lerna.json
{
  "extends": "./lerna.js"
}

This approach allows you to compute configuration values at runtime, read from external sources, or implement complex conditional logic.

Selective Package Execution with Scope and Ignore

Fine-grained control over which packages Lerna operates on is achieved through scope and ignore patterns, both at the configuration level and command line.

{
  "command": {
    "run": {
      "scope": ["@myorg/core", "@myorg/utils"],
      "ignore": ["@myorg/deprecated-*", "*-internal"]
    }
  }
}

Command-line overrides take precedence:

# Override scope for a single run
lerna run test --scope="@myorg/core" --scope="@myorg/utils"

# Exclude specific packages
lerna run build --ignore="@myorg/legacy-*"

# Combine scope and ignore
lerna run lint --scope="@myorg/*" --ignore="@myorg/tools-*"

The --since flag is particularly powerful—it limits operations to packages that changed since a specific Git reference:

# Only test packages changed since main branch
lerna run test --since=main

# Build packages changed since last tag
lerna run build --since=last-tag

# Lint packages changed in the last commit
lerna run lint --since=HEAD~1

Conventional Commits Integration

Enabling conventional commits transforms your versioning workflow from manual to automatic. Here's a complete configuration:

{
  "version": "independent",
  "command": {
    "version": {
      "conventionalCommits": true,
      "changelog": {
        "types": [
          { "type": "feat", "section": "✨ Features" },
          { "type": "fix", "section": "🐛 Bug Fixes" },
          { "type": "perf", "section": "⚡ Performance" },
          { "type": "docs", "section": "📖 Documentation" },
          { "type": "style", "section": "💅 Code Style", "hidden": true },
          { "type": "refactor", "section": "♻️ Refactoring" },
          { "type": "test", "section": "🧪 Testing", "hidden": true },
          { "type": "chore", "section": "🔧 Maintenance", "hidden": true },
          { "type": "ci", "section": "🚀 CI/CD", "hidden": true }
        ]
      },
      "includeMergedTags": true,
      "allowBranch": ["main"]
    }
  }
}

With this setup, commit messages like feat: add user authentication trigger a minor version bump, fix: resolve login timeout triggers a patch bump, and feat!: redesign API (with the ! indicating breaking change) triggers a major bump. Lerna reads commit history since the last release tag to determine the appropriate version increment.

Multi-Registry Publishing

For organizations using multiple registries, Lerna supports per-package registry configuration through publishConfig in individual package.json files:

// packages/internal-utils/package.json
{
  "name": "@myorg/internal-utils",
  "version": "1.0.0",
  "publishConfig": {
    "registry": "https://private-registry.myorg.com"
  }
}

// packages/public-utils/package.json
{
  "name": "@myorg/public-utils",
  "version": "1.0.0",
  "publishConfig": {
    "registry": "https://registry.npmjs.org",
    "access": "public"
  }
}

Lerna respects these per-package configurations during publish, allowing seamless hybrid public/private workflows.

Complete lerna.json Reference

Here is a comprehensive lerna.json file showcasing all major configuration options in one place:

{
  "version": "independent",
  "packages": [
    "packages/*",
    "shared/*",
    "!private/*"
  ],
  "npmClient": "yarn",
  "useWorkspaces": true,
  "command": {
    "publish": {
      "ignoreChanges": [
        "**/__fixtures__/**",
        "**/__tests__/**",
        "**/*.md",
        "**/*.test.{js,ts,tsx}",
        "**/*.spec.{js,ts,tsx}"
      ],
      "message": "chore(release): publish %v",
      "registry": "${NPM_REGISTRY:-https://registry.npmjs.org}",
      "verifyAccess": true,
      "verifyConditions": true,
      "allowBranch": ["main", "master", "release/*"],
      "includePrivate": false,
      "includeMergedTags": true,
      "tagVersionPrefix": "v"
    },
    "version": {
      "allowBranch": ["main", "master"],
      "conventionalCommits": true,
      "exact": true,
      "includeMergedTags": true,
      "message": "chore(release): bump versions to %v",
      "noCommitHooks": false,
      "push": true,
      "sign": false,
      "yes": false,
      "preid": "beta",
      "changelog": {
        "types": {
          "feat": { "section": "Features" },
          "fix": { "section": "Bug Fixes" },
          "docs": { "section": "Documentation" },
          "perf": { "section": "Performance" }
        }
      }
    },
    "run": {
      "npmClient": "yarn",
      "stream": true,
      "parallel": false,
      "concurrency": 4,
      "bail": true,
      "prefix": true,
      "ignore": ["@myorg/dev-tools"]
    },
    "exec": {
      "stream": true,
      "bail": true,
      "concurrency": 4
    },
    "add": {
      "dev": false,
      "exact": true,
      "peer": false
    },
    "clean": {
      "yes": true
    }
  },
  "loglevel": "info",
  "progress": true
}

Best Practices for Lerna Configuration

1. Always Use Workspaces

Enable useWorkspaces: true and define workspaces in root package.json. This is the modern, faster, and officially recommended approach. It eliminates the need for lerna bootstrap and reduces dependency duplication.

{
  "useWorkspaces": true,
  "npmClient": "yarn"
}

2. Prefer Independent Versioning for Heterogeneous Packages

If your packages serve different purposes and have different release cycles, use "version": "independent". This prevents unnecessary version bumps for unchanged packages and gives you granular control.

{
  "version": "independent"
}

3. Lock Down Publishing with allowBranch

Always configure allowBranch to prevent accidental releases from feature branches:

{
  "command": {
    "publish": {
      "allowBranch": ["main", "release/*"]
    },
    "version": {
      "allowBranch": ["main", "release/*"]
    }
  }
}

4. Leverage ignoreChanges Aggressively

Prevent documentation, test, and configuration changes from triggering unnecessary version bumps:

{
  "command": {
    "publish": {
      "ignoreChanges": [
        "**/*.md",
        "**/*.test.{js,ts}",
        "**/jest.config.js",
        "**/tsconfig.json",
        "**/.eslintrc.js"
      ]
    }
  }
}

5. Use Conventional Commits for Predictable Versioning

Enable conventionalCommits: true and enforce commit message conventions in your team. This removes human judgment from version bump decisions:

{
  "command": {
    "version": {
      "conventionalCommits": true,
      "exact": true
    }
  }
}

6. Set concurrency Limits in CI

Unlimited parallelism can overwhelm CI servers. Set sensible concurrency limits:

{
  "command": {
    "run": {
      "concurrency": 4,
      "stream": true,
      "bail": true
    }
  }
}

7. Keep the Root package.json Private

Never forget "private": true in the root package.json. Accidentally publishing the monorepo root is a painful mistake:

{
  "name": "my-monorepo",
  "private": true
}

8. Centralize Common devDependencies

Place TypeScript, ESLint, Jest, Prettier, and other dev tools in the root devDependencies. Workspace hoisting makes them available to all packages without duplication:

{
  "devDependencies": {
    "typescript": "^5.3.0",
    "@typescript-eslint/eslint-plugin": "^7.0.0",
    "jest": "^29.0.0",
    "prettier": "^3.2.0",
    "lerna": "^8.0.0"
  }
}

9. Use --since for Efficient CI Pipelines

Combine Lerna with --since to only build, test, and lint packages that actually changed:

// Root package.json scripts
{
  "scripts": {
    "build:changed": "lerna run build --since=origin/main --stream",
    "test:changed": "lerna run test --since=origin/main --stream",
    "lint:changed": "lerna run lint --since=origin/main --stream"
  }
}

10. Version and Publish in Separate Steps in CI

In automated CI pipelines, separate versioning and publishing into distinct stages with manual approval gates:

# Stage 1: Version bump (creates commit and tag)
lerna version --conventional-commits --yes --no-push

# Manual approval gate in CI

# Stage 2: Publish (pushes tags and publishes to registry)
lerna publish from-package --yes

This prevents unreviewed version bumps from reaching the registry.

Migration Guide: From Legacy Lerna to Modern Lerna

If you're maintaining an older Lerna project that doesn't use workspaces, here's the migration path:

// Step 1: Update lerna.json
{
  "useWorkspaces": true,
  "npmClient": "yarn"
}

// Step 2: Add workspaces to root package.json
{
  "private": true,
  "workspaces": ["packages/*"]
}

// Step 3: Remove lerna bootstrap from scripts
// Replace "lerna bootstrap" with "yarn install" or equivalent

// Step 4: Run a fresh install
yarn install

// Step 5: Verify package linking
ls -la node_modules/@myorg

After migration, you'll notice faster installs, less disk usage, and simpler scripts. The lerna bootstrap command becomes unnecessary and can be removed from all CI pipelines and documentation.

Troubleshooting Common Configuration Issues

Issue: Packages Not Found

If Lerna reports "no packages found", verify your glob patterns:

# Debug which packages Lerna discovers
lerna list --all

# Check your patterns
# lerna.json
{
  "packages": ["packages/*"]  # Only matches one level deep
}

# For nested packages, use recursive patterns
{
  "packages": ["packages/**"]  # Matches any depth
}

Issue: Symlinks Not Working

With useWorkspaces: true, ensure your root package.json has matching workspaces:

// Must match!
// lerna.json
{ "packages": ["packages/*"] }

// package.json
{ "workspaces": ["packages/*"] }  // Same patterns

Issue: Conventional Commits Not Detecting Bumps

Lerna's conventional commit detection relies on proper commit format since the last version tag:

# Check what Lerna sees
lerna version --conventional-commits

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles