Renovate at scale: what we run across 50+ repos
The preset architecture behind one opinionated Renovate configuration across 50+ repos: two composed presets, a 14-day soak window, provider grouping, and the tradeoffs.
Pinning a Terraform provider is one line. Keeping 50+ repos current on the providers they pin is a platform problem.
That is the gap Renovate sits in.
We have been running it across more than 50 repos. Every repo runs the same opinionated configuration. Not because we wrote 50+ configs, but because each repo extends two presets.
This article is the design narration. Why the preset architecture looks the way it does, why we picked Renovate over Dependabot in the first place, which defaults we hardened, what we deliberately do not test, and where the tradeoffs sit.
Part 1: The shape
Each consuming repo ships seven lines:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github><your-org>/<your-presets-repo>//renovate-presets/renovate-base-config.json5",
"github><your-org>/<your-presets-repo>//renovate-presets/renovate-terraform.json5"
]
}
That is it.
Everything else (the soak window, the branch prefix, the provider grouping, the exclusions, the validation assumptions) lives in the platform team’s preset repo. One source of truth. Two presets composed.
The split:
renovate-base-config.json5is the universal layer. Every consuming repo extends it.renovate-terraform.json5is the Terraform layer. Repos that manage Terraform extend it on top of the base.
A non-Terraform repo extends just the base. A Terraform repo extends both. Future composed presets (a Helm preset, a Go preset) can drop in without rewriting the base.
Part 2: Why Renovate, not Dependabot
The first question is usually “why not Dependabot?” Dependabot ships with GitHub, costs nothing, and would have been less work to enable.
The answer is the regex manager.
Dependabot understands a fixed set of ecosystems. Renovate’s custom regex manager lets you teach it patterns that none of the built-in managers cover. The pattern we needed: tool versions passed as parameters to native Azure Pipelines and GitHub Actions tasks.
- task: TerraformInstaller@1
displayName: Install Terraform
inputs:
terraformVersion: 1.13.4
That 1.13.4 is a hard-coded string inside a YAML file. Dependabot does not see it. Renovate, with a regex pattern, does. Same shape repeats across other CLI tools we pin through task inputs rather than declaring through a package manager.
Part 3: The base preset
The base layer is the universal one. Every consuming repo extends this, Terraform repos or not.
"extends": [
"config:recommended",
"config:best-practices",
":separateMajorReleases",
":disableDependencyDashboard"
]
Note:
config:basewas deprecated in Renovate v36. We useconfig:recommended, the current baseline. If your config still extendsconfig:base, the:configMigrationrule handles the rename for you.
config:best-practices is the more interesting choice. It pulls in :configMigration, abandonments:recommended, helpers:pinGitHubActionDigests, docker:pinDigests, :maintainLockFilesWeekly, and security:minimumReleaseAgeNpm.
Most of those are dormant on a Terraform-only repo today. We added them anyway.
The reason: the base is not a Terraform preset. It is the platform team’s universal layer, which Terraform happens to extend first. When a .NET, JS, or Docker composed preset extends this base later, the security defaults are already wired in. GitHub Actions digests pinned. Docker image digests pinned. Lock files maintained weekly. The next tier’s repos get those defaults the day they extend the base, not the day someone remembers to add them.
The cost of inheriting six dormant defaults today is zero. The cost of bolting them on later, after six different teams have already extended a thinner base, is real.
The 14-day soak window
"minimumReleaseAge": "14 days"
Renovate’s default is to open a PR the moment a new version is published. We do not want that.
A version released five minutes ago has been tested by the maintainer’s team and a handful of early adopters. After 14 days, it has been tested by everyone who is not us. First releases of new majors sometimes ship with regressions the maintainer did not catch. CVEs occasionally get introduced in the same release that fixes other CVEs. 14 days gives the community time to surface those.
The obvious objection: “you are delaying CVE patches by 14 days.” That would be true, if we left the soak window alone. We did not.
"vulnerabilityAlerts": {
"enabled": true,
"labels": ["security"]
}
This block wires Renovate into the repo’s GitHub Dependabot Alerts feed. When a CVE is published against something we depend on, Renovate opens a PR immediately, bypassing minimumReleaseAge. The security label distinguishes it from routine bumps in the PR list, so reviewers know what they are looking at before they open it.
The honest detail: this needs the Dependabot alerts: Read-only permission on the GitHub App. Without that permission, the block is inert. No error, no warning, just a silent no-op. We caught this from someone else’s writeup before turning the soak window on, which is why minimumReleaseAge and vulnerabilityAlerts went in together rather than weeks apart. Reading other people’s mistakes is cheap.
So the real shape of the tradeoff: non-security updates wait 14 days. Security patches do not. The soak window is the throttle. The vulnerabilityAlerts feed is the bypass. Both are needed for the claim to hold up in production.
Part 4: The Terraform preset
The base ships universal defaults. The Terraform layer composes on top with Terraform-specific decisions.
Provider grouping: bundle the routine, split the breaking
{
"description": "Consolidate all Terraform provider updates",
"matchManagers": ["terraform"],
"matchDatasources": ["terraform-provider"],
"groupName": "Terraform Providers",
"rangeStrategy": "bump",
"versioning": "hashicorp",
"automerge": false,
"addLabels": ["terraform-providers"],
"separateMinorPatch": false,
"separateMultipleMajor": true
}
A typical Terraform repo of ours uses azurerm, azapi, azuread, random, time, and a few more. Without grouping, that is six separate PRs every time one of them releases. Multiply by 50+ repos and the noise drowns the signal.
We bundle the routine. We split the breaking.
separateMinorPatch: false collapses every patch and minor across every provider into one shared “Terraform Providers” PR. Routine bumps land together, reviewed together, validated by one terraform plan run. Most weeks, that one PR is the only Renovate activity in a repo.
separateMultipleMajor: true peels each destination major out onto its own branch. A ~> 3.x → ~> 4.x bump for azurerm lands as its own PR on renovate/major-4-terraform-providers. A ~> 2.x → ~> 3.x bump for azuread lands separately on renovate/major-3-terraform-providers. Each cross-major change carries its own changelog, its own breaking-change surface, and its own review cycle. Bundling them would put multiple unrelated breaking changes into a single PR competing for the reviewer’s attention.
The tradeoff: when one provider in the patch+minor bundle has a regression, the whole group sits until that is resolved. We accept this. The alternative, six PRs per repo per release wave, guarantees nothing gets reviewed.
Terraform CLI itself is on its own track. CLI upgrades have a different blast radius than provider upgrades and deserve to be evaluated separately.
What every PR runs
A Renovate PR is not a merge button. Every PR triggers a validation pipeline against stable environments:
terraform fmt -checkterraform validatetflint --recursiveterraform planagainst stable environments
Provider grouping makes this practical. Running plan once for a bundled “Terraform Providers” PR is cheap. Running plan six times for six split PRs is not.
If validation fails, the PR sits. The custom PR title and renovate label make it easy to filter the queue. Most weeks the queue is short.
Part 5: What we do not test, and the deal we are making
Every Renovate PR runs validate, lint, format-check, and plan. None of those test apply against a real environment.
terraform plan runs terraform init first, so init failures get caught. What plan does not catch is runtime failure at apply time. A name collision with an existing resource. A permission missing on the identity doing the apply. A region quirk that only shows up when the resource is actually created.
We accept this risk. The alternative, a full apply against a throwaway environment on every Renovate PR, would cost more than the failure mode it prevents. For now. As the platform grows, that calculation changes. Today, it is an accepted gap.
The honest version: we know the PR is syntactically valid and would plan cleanly against current state. We do not know whether every apply would land cleanly until we run it.
What changed for the teams consuming it
We did not start from a fragmented Renovate landscape. We started with global presets from day one, so there were never per-repo Renovate configs to consolidate. We also never ran Dependabot. We went straight from no dependency automation to this setup.
What we were doing badly: neglecting updates. Providers, CLI versions, GitHub Actions, all aging in place. The presets did not replace a worse system. They replaced no system.
After the presets:
- 50+ repos run the same defaults.
- Updates are continuous (with the soak buffer), not reactive.
- Provider bumps land as one bundled PR per repo per cycle for patch and minor. Cross-major bumps surface as their own PR per destination major, reviewable independently.
- Reviewers know what to expect. Same title, same labels, same validation pipeline.
- The platform team can roll out a new defaults decision once, in one repo, and it ships to every consuming repo.
That last point is the platform-team payoff. Decisions live in one place. Adoption is by extension, not by copy-paste.
Where the tradeoff lives
This whole article is a tradeoff: convention over configuration.
A team that extends both presets accepts our 14-day soak, our provider grouping, our per-major PR routing. The escape hatch is right there. Every repo owns its own renovate.json5, and any setting can be overridden locally.
In practice, no team does. They have no reason to and no context to. The whole point of the preset is that they stop thinking about it, and what gets shipped without thinking should be the thing the platform team thinks is best practice.
So the deal: in exchange for the opinionated default, the platform team owns the rationale, the consuming team owns the code, and best practice ships by default without anyone having to opt into it.
When does an opinionated default become a constraint that needs to flex, and when does flex become drift?
Originally published as a LinkedIn article, May 2026.