A few tips for optimising Renovate for multi-team monorepos

Featured image for sharing metadata for article

At Elastic, we're using Renovate (as it's the best) to keep a number of internal and external dependencies up-to-date across a lot of repositories in the organisation.

As part of the hundreds of repositories using Renovate, we have a number of big multi-team monorepos.

For these repos, we're using the default configuration from Renovate, and then applying the best practices configuration preset, too, as well as a few other repo-specific tweaks.

When Renovate raises a PR - using these defaults - it'll try and raise a single PR to update a given dependency, modifying all the package files (build.gradle.kts, go.mod, Dockerfile, etc) it can do in one go.

For instance, say we're updating golang:1.22-alpine to golang:1.23-alpine, and there are 20 different projects within the monorepo that are using this package, Renovate will only raise a single PR for this.

(Note that I'll use the term "project" to denote a subproject/submodule/component within the monorepo)

This is a reasonable default, and very much follows what one of the key benefits of a monorepo is - being able to atomically update multiple projects.

However, in this case, we've found that we want to provide teams with more autonomy on their dependency updates, to split up these PRs down to a project-level. This allows each project/team to manage their own PR(s) independently from other projects, and reduces cases that one project within the monorepo fails to build blocks everyone else getting an update in.

As part of doing this and a few other considerations for improving monorepo experience, I've picked up some tips for making this experience a little better, so wanted to write it up as a form of blogumentation.

I've also been using this setup on a monorepo that only my team works with, but there are distinct components that want their own configuration, and it's helped improve the experience significantly.

Complete example

We'll see each section explained in more detail, but as a quick reference, here's what I recommend:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "packageRules": [
    // NOTE that packageRules are order dependent
    // You should consider the section of configuration (and therefore precedence) you want to put a rule in

    // ********************************************************************************
    // GLOBAL SETTINGS
    // These settings take initial precedence, but can be ignored in later sections
    {
      description: 'Require each project opt-in to any updates they\'re performing, instead of using Renovate\'s default of "everything on by default", which is good, but may not always make sense for a large monorepo',
      matchPackageNames: [
        '*'
      ],
      enabled: false
    },

    {
      description: 'Ensure that each project has their own set of dependency updates, split by the parent directory of the package file (`packageFileDir`). Groups will be unaffected.',
      "matchFileNames": [
        "**/*"
      ],
      // by making sure that `additionalBranchPrefix` has a `packageFileDir` in there, we can split a single dependency update into one per parent directory of the package file (`packageFileDir`)
      // NOTE that we need to make sure we handle groups separately, so they work as expected (as they may be across multiple package files and should retain precedence
      "additionalBranchPrefix": "{{#if isGroup }}{{ else }}{{packageFileDir}}/{{/if}}",
      // this is needed to show this in the PR title (which is taken from the commit message) as well as the Dependency Dashboard
      // NOTE that we need to make sure we handle groups separately, so they work as expected (as they may be across multiple package files and should retain precedence
      "commitMessageSuffix": "{{#if isGroup }}{{ else }} ({{packageFileDir}}){{/if}}"
    },

    // ...

    // ********************************************************************************
    // PER PROJECT SETTINGS
    // These settings should be used to manage configuration for a given project in the monorepo
    // NOTE that you /likely/ want to add a rule here, at the end of the block, but it could be you may want to put it somewhere else to be higher/lower precedence than other rules

    // NOTE that this is an example of a rule - it doesn't match a real project name / path
    {
      description: '[APK Builder] Allow managing Go + Docker-y updates',
      matchFileNames: [
        'projects/internal/apk-builder/**',
      ],
      matchManagers: [
        // Go
        'gomod',

        // Docker-y
        'buildpacks',
        'devcontainer',
        'docker-compose',
        'dockerfile',

        // Regex manager, which may match other files
        'custom.regex',
      ],
      enabled: true,
    },
    // then we have a rule to group certain dependencies for this project
    {
      description: '[APK Builder] Group OS updates',
      groupName: 'APK Builder OS',
      matchFileNames: [
        'projects/internal/apk-builder/**',
      ],
      matchManagers: [
        'devcontainer',
        'docker-compose',
        'dockerfile',
      ],
      enabled: true,
    },

    // ...

    // ********************************************************************************
    // FINAL SETTINGS
    // These settings take final precedence, being used for "overrides". It's likely that you don't want to set a setting here unless you're /really sure/

    // for example
    {
      matchManagers: [
        'gomod',
      ],
      matchDepTypes: [
        'indirect',
      ],
      enabled: false
    },

    // ...
  ]
}

JSON5/JSONC

Although not strictly required this is a heavily recommended one from me.

Renovate's JSON5 (and newly added JSONC) support makes it much more straightforward to reason and document a large monorepo's Renovate configuration.

As folks may not be super familiar with the way that the definitions of packageRules follow their precedence (with the last defined rule "winning"), it can be very handy to provide some guardrails with the configuration.

For instance, to ensure that folks are more aware of this, we can provide the overarching sections in our configuration:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "packageRules": [
    // ...

    // ********************************************************************************
    // GLOBAL SETTINGS
    // These settings take initial precedence, but can be ignored in later sections

    // ...

    // ********************************************************************************
    // PER PROJECT SETTINGS
    // These settings should be used to manage configuration for a given project in the monorepo
    // NOTE that you /likely/ want to add a rule here, at the end of the block, but it could be you may want to put it somewhere else to be higher/lower precedence than other rules

    // ...

    // ********************************************************************************
    // FINAL SETTINGS
    // These settings take final precedence, being used for "overrides". It's likely that you don't want to set a setting here unless you're /really sure/

    // ...
  ]
}

As seen in the complete example, these then allow us to tune specific rules, with a better set of guardrails for teams.

Splitting updates by package file directory (packageFileDir)

As mentioned, one issue with Renovate's defaults are that they intend for atomic dependency bumps across the repository.

However, when you have a monorepo with distinct teams, you don't necessarily want this behaviour.

The best solution I've found for splitting up these PRs based on the project within the monorepo they're for is to use additionalBranchPrefix like so:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "packageRules": [
    {
      description: 'Ensure that each project has their own set of dependency updates, split by the parent directory of the package file (`packageFileDir`)',
      "matchFileNames": [
        "**/*"
      ],
      // NOTE that this is not the final version - see below
      "additionalBranchPrefix": "{{packageFileDir}}/",
    },

From here, Renovate will split up a single PR to bump golang:1.22-alpine into branches like projects/internal/apk-builder/golang (or similar).

However, this is not complete configuration. Although this creates the multiple branches, it can be hard to see at-a-glance which project they're targeting.

In particular we can see this on the Dependency Dashboard, which goes from:

  • chore(deps): update dependency golang:1.22-alpine to golang:1.23-alpine

To now one entry per packageFileDir:

  • chore(deps): update dependency golang:1.22-alpine to golang:1.23-alpine
  • chore(deps): update dependency golang:1.22-alpine to golang:1.23-alpine
  • chore(deps): update dependency golang:1.22-alpine to golang:1.23-alpine

Unfortunately it's very unclear which project is being updated here.

To improve this, we can then make the following configuration, modifying the commitMessageSuffix like so:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "packageRules": [
    {
      description: 'Ensure that each project has their own set of dependency updates, split by the parent directory of the package file (`packageFileDir`)',
      "matchFileNames": [
        "**/*"
      ],
      // NOTE that this is not the final version - see below
      "additionalBranchPrefix": "{{packageFileDir}}/",
      "commitMessageSuffix": " ({{packageFileDir}})"
    },

This now means that our Dependency Dashboard - and PR titles - contain indication of the packageFileDir they're targeting, such as:

  • chore(deps): update dependency golang:1.22-alpine to golang:1.23-alpine (projects/internal/apk-builder)
  • chore(deps): update dependency golang:1.22-alpine to golang:1.23-alpine (.github/workflows)

This is awesome, and it's now much clearer what's going on.

From here, we can then make a final tweak to make sure that we're always handling the grouping of packages, treating them as the highest precedence:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "packageRules": [
    {
      description: 'Ensure that each project has their own set of dependency updates, split by the parent directory of the package file (`packageFileDir`). Groups will be unaffected.',
      "matchFileNames": [
        "**/*"
      ],
      "additionalBranchPrefix": "{{#if isGroup }}{{ else }}{{packageFileDir}}/{{/if}}",
      "commitMessageSuffix": "{{#if isGroup }}{{ else }} ({{packageFileDir}}){{/if}}"
    },

Dependency Dashboard (+ without GitHub/GitLab Issues)

Renovate's Dependency Dashboard is a key entrypoint into understanding how Renovate is working, and what's detected/pending for an update.

It's very much recommended to have it enabled regardless, and moreso when you have a very large monorepo with many branches being updated, and many updates that may want to be requested.

Unfortunately there are limits in how many characters the GitHub/GitLab APIs allow for a comment on an Issue, so you're likely to find in large monorepos that the Dependency Dashboard gets truncated.

This is even more likely to happen if you split PRs by package file directory (packageFileDir), as a single PR is likely to get split into many.

Because of this, it's very much recommended to use the Dependency Dashboard in Mend Renovate Cloud, or follow Accessing your Renovate Dependency Dashboard, without GitHub/GitLab Issues enabled to allow using the Dependency Dashboard without hitting limits in GitHub/GitLab.

Increased prConcurrentLimit / branchConcurrentLimit

Similar to the above, something you may start to see is Renovate being throttled ("rate limited") by the number of open branches or PRs it can create.

If you have a large monorepo with dozens of dependencies that need updating, the default of prConcurrentLimit=10 can seem incredibly low, especially if PRs aren't being reviewed/merged very quickly, so a slow moving set of PRs in one project can slow down the ability for other projects to get their relevant updates.

The best option here is to carefully tune prConcurrentLimit (or branchConcurrentLimit) to a relevant setting.

I'll not go into the specifics here, but one thing to be careful about is that the more PRs/branches Renovate needs to create/update results in more time Renovate takes to execute.

If you're running as a GitHub App, this may approach the 1 hour GitHub App Installation Access Token lifetime, or if you're using Mend Renovate Community Edition, this can block the processing of any other repositories during that time.

Opt-out by default

Something also not strictly required but recommended is to make it an opt-in process to start using Renovate in the monorepo.

By default - and one thing I absolutely love - is that Renovate will go and manage every dependency it can find.

As Renovate onboards a repository - not a project within the monorepo - at a time, you may have one team in the monorepo who want to use Renovate, but that doesn't mean everyone does.

However, when you're in a large monorepo, particularly one with different teams, you should try and treat each each project as their own small repository, which means you want to allow each team to opt in as they are ready to.

To do this, we want to apply the global configuration:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "packageRules": [
    //...

    // ********************************************************************************
    // GLOBAL SETTINGS
    // These settings take initial precedence, but can be ignored in later sections
    {
      description: 'Require each project opt-in to any updates they\'re performing, instead of using Renovate\'s default of "everything on by default", which is good, but may not always make sense for a large monorepo',
      matchPackageNames: [
        '*'
      ],
      enabled: false
    },

This then makes it so no dependencies are managed by Renovate.

From here, then a team/project can re-enable with a rule such as:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "packageRules": [
    //...

    // ********************************************************************************
    // PER PROJECT SETTINGS
    // These settings should be used to manage configuration for a given project in the monorepo

    // ...
    {
      description: '[APK Builder] Allow managing Go + Docker-y updates',
      matchFileNames: [
        'projects/internal/apk-builder/**',
      ],
      matchManagers: [
        // Go
        'gomod',

        // Docker-y
        'buildpacks',
        'devcontainer',
        'docker-compose',
        'dockerfile',

        // Regex manager, which may match other files
        'custom.regex',
      ],
      enabled: true,
    },

Closing

These tips have made working with the large monorepos I and others interact with (and have oversight of as Renovate platform owner) a much nicer experience, and hopefully help you too!

Got any other tips for monorepos? Let me know!

Written by Jamie Tanna's profile image Jamie Tanna on , and last updated on .

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under the GNU Affero General Public License v3.0 only.

#blogumentation #renovate.

This post was filed under articles.

Interactions with this post

Interactions with this post

Below you can find the interactions that this page has had using WebMention.

Have you written a response to this post? Let me know the URL:

Do you not have a website set up with WebMention capabilities? You can use Comment Parade.