Configuring Renovate for a monorepo Jump to heading

Pointing a dependency bot at a monorepo with default settings produces a specific, memorable failure: one pull request per package per dependency, all touching the same lockfile, all conflicting with each other. Twelve workspaces sharing a test framework generates twelve pull requests for one version bump. The fix is to tell the bot about the repository’s structure rather than letting it infer one from the manifests it can see, extending automated dependency updates.

When to use this approach Jump to heading

  • Several packages live in one repository and share a lockfile.
  • The same dependency appears in multiple manifests at the same version.
  • Some packages depend on others in the same repository.
  • Update pull requests currently conflict with each other more often than they merge.
  • If the repository has one manifest, the default configuration is already correct; start from the parent topic instead.

Step 1 — Declare the workspace layout explicitly Jump to heading

Autodiscovery finds manifests, not intent. Listing the paths makes the bot’s view match the build tool’s view.

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "includePaths": ["package.json", "packages/**", "services/**", "tools/**"],
  "ignorePaths": ["**/fixtures/**", "**/__tests__/**", "examples/**"],
  "postUpdateOptions": ["npmDedupe"]
}
# Verification: which manifests does the bot consider in scope?
npx --yes renovate --dry-run=full --platform=local 2>&1 | grep -i 'packageFile' | sort -u

The ignore list is doing real work. Test fixtures frequently contain deliberately old manifests, and a bot that updates them breaks the tests those fixtures exist to support.

What the bot sees versus what the build seesAutodiscovery treats every manifest as an independent project, including fixtures and examples. Declaring the workspace paths aligns the bot with the build tool, so one version bump becomes one change across the repository rather than one per manifest.aligning the bot with the buildAutodiscovered manifestsevery package.json, including fixturesDeclared includePathsonly real workspacesOne lockfileresolved once, updated oncethe lockfile at the bottom is why per-package updates conflict

Step 2 — Group by the thing that changes together Jump to heading

In a monorepo the useful grouping is usually per dependency across all packages, not per package across all dependencies.

{
  "packageRules": [
    {
      "description": "One pull request per dependency, covering every workspace that uses it",
      "matchPackagePatterns": ["*"],
      "groupName": "{{depName}}",
      "groupSlug": "dep-{{depNameSanitized}}"
    },
    {
      "description": "Test and build tooling moves together",
      "matchPackageNames": ["jest", "ts-jest", "@types/jest", "vitest", "esbuild"],
      "groupName": "test and build tooling"
    }
  ]
}
# Verification: count how many branches a dry run would create
npx --yes renovate --dry-run=full --platform=local 2>&1 | grep -c 'branchName'

What changed: bumping a shared framework now produces one branch that edits every manifest using it, so the lockfile is regenerated once and the change is reviewed as a single decision.

Step 3 — Keep internal packages out of the stream Jump to heading

Workspace packages that depend on each other are resolved by the build tool, not fetched from a registry. Updating them through the bot produces churn with no meaning.

{
  "packageRules": [
    {
      "matchPackagePatterns": ["^@acme/"],
      "matchDepTypes": ["dependencies", "devDependencies"],
      "enabled": false,
      "description": "Internal workspace packages are linked, not fetched"
    }
  ]
}
# Verification: internal names resolve to workspace links, not registry versions
npm ls --workspaces --depth=0 2>/dev/null | grep '@acme/' | head
Deciding whether the bot should touch a dependencyA package resolved from the registry is a genuine external dependency and belongs in the update stream. A package resolved to a sibling workspace is linked by the build tool, so a version bump in its manifest changes nothing real.Where does this dependency resolve from?a registryIn the update streamversion mattersa sibling workspaceDisabled for the botlinked, not fetchedinternal version bumps are churn: the build already uses the local copy

Step 4 — Contain the lockfile churn Jump to heading

Even with perfect grouping, several branches will be open at once and each holds a different lockfile. Make regeneration the standard resolution rather than a manual merge.

# .gitattributes — the lockfile is generated; do not merge it line by line
echo 'package-lock.json  merge=ours  linguist-generated=true' >> .gitattributes
git config merge.ours.driver true
# Rebase an update branch and regenerate rather than resolving
git rebase origin/main || true
git checkout origin/main -- package-lock.json
npm install --package-lock-only --workspaces
git add package-lock.json && git rebase --continue
# Verification: the regenerated file is deterministic
npm install --package-lock-only --workspaces && git diff --exit-code package-lock.json \
  && echo "lockfile is stable"

SAFETY WARNINGmerge=ours on a lockfile means Git will silently keep your side during a merge. That is correct only because the file is regenerated immediately afterwards. If a branch merges without the regeneration step, the lockfile no longer matches the manifests and the next install resolves versions nobody tested. Make the regeneration part of the pipeline, not part of a habit.

Why a shared lockfile turns parallel branches into conflictsThree update branches each rebuild the same lockfile from a slightly different manifest set. Whichever merges first invalidates the other two, so the resolution is always to discard and regenerate rather than to merge the file.Three branchesone manifest eachOne lockfilerewritten by all threeFirst merge winsother two conflictRegenerateinstall --package-lock-onlynever hand-resolve a generated file: rebuild it from the manifests that won

Step 5 — Cap concurrency to what the pipeline can absorb Jump to heading

Each update branch runs the full pipeline. In a monorepo that is expensive unless the pipeline is already path-filtered.

{
  "prConcurrentLimit": 4,
  "branchConcurrentLimit": 6,
  "rebaseWhen": "conflicted"
}
# Verification: how much pipeline time do open update branches represent?
gh pr list --label dependencies --json number --jq 'length' | \
  awk '{printf "%d open update PRs\n", $1}'

rebaseWhen: conflicted is the setting that keeps cost sane. The default rebases branches whenever the default branch moves, and in an active monorepo that means every update branch re-runs the pipeline several times a day for no new information. The path filters in optimizing CI triggers for path-specific changes cut the remaining cost further.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should each workspace have its own bot configuration? Jump to heading

No — one configuration at the repository root with package rules is easier to reason about and avoids contradictory settings. Per-package overrides belong in packageRules entries matched by path, which keeps every decision visible in one file.

How do we handle a dependency that must differ between packages? Jump to heading

Match the path and set an explicit rule for it, with a comment saying why the divergence exists. Without the comment, the next person removes the rule to “clean up”, and the divergence returns as a bug.

What about the repository’s own release versions? Jump to heading

Keep them out of the update stream entirely. Internal version numbers are set by the release process described in release tagging and versioning, and a dependency bot editing them creates a second source of truth.