A Terraform block in dependabot.yml looks identical to an npm or uv one:
- package-ecosystem: terraform
directory: /terraform/env
schedule:
interval: weekly
It behaves nothing like one. We rolled Dependabot out across a GCP monorepo — four deployable services, eight Terraform directories, a shared infra root — and the Terraform half cost more time than every other ecosystem combined. Here is what actually bit us, and what the fixes look like.
1. directory takes one literal path, so a monorepo means N copies of the same block
There is no globbing in the singular directory key. Our repo splits Terraform ownership: Cloud Run services and their observability live under each service's own terraform/, while service accounts, IAM, shared VPC and Cloud SQL live in a root terraform/. That is eight directories:
/terraform/env
/terraform/common
/app-a/terraform
/services/svc-a/terraform
/services/svc-b/terraform
/services/svc-c/terraform
/app-b/terraform
/app-b/terraform/sub
Eight near-identical 15-line entries, each with its own copy of the schedule, the PR limit, the labels and the group block. Changing the cadence means editing eight places. Adding a service means remembering to add a ninth.
The fix is the plural directories key, which does support globs — and the docs are explicit that the singular key does not:
- package-ecosystem: terraform
directories:
- "/terraform/*"
- "/*/terraform"
- "/services/*/terraform"
schedule:
interval: weekly
This is the single highest-leverage change available, and it is easy to miss because every blog post and every generated starter config uses the singular form.
2. google and google-beta drift apart, and the PR limit guarantees it
The GCP provider ships as two packages that must move together. If google is on 8.x and google-beta is still on 7.x, terraform init starts failing on resources that exist in both.
Dependabot treats them as two independent dependencies. Combine that with the conventional open-pull-requests-limit: 1 for Terraform, and you get a PR that bumps exactly one of the pair — with no indication that the other half exists. Here is what one week produced in our repo:
#1206 bump hashicorp/google 7.44.0 -> 8.8.1 in /app-a/terraform
#1207 bump hashicorp/google-beta 7.45.0 -> 8.8.1 in /services/svc-c/terraform
#1203 bump hashicorp/google 7.44.0 -> 8.8.1 in /services/svc-b/terraform
Three directories, three PRs, and each one covers a different half of the pair. Merge any of them in isolation and that directory is in a split state. Nothing in the PR tells you this; the title reads like a routine patch bump.
The fix is a group with a catch-all pattern, one per directory, so the directory's providers move as a unit:
groups:
terraform-providers:
patterns:
- "*"
Note what is deliberately missing: update-types. For the other ecosystems we restrict the catch-all group to minor and patch so that majors arrive as isolated, reviewable PRs. For Terraform providers you want the opposite — the major must travel with its sibling, so the group has to swallow majors too.
3. The shape of the PR depends on whether the lock file is committed
.terraform.lock.hcl is committed in some of our directories and gitignored in others. That one difference produces three distinct kinds of PR from the same ecosystem:
| Directory state | What the PR actually changes |
|---|---|
Lock committed, constraint too narrow (~> 7.0) |
required_providers constraint and the lock file |
Lock gitignored (/terraform/env) |
Constraint only — a one-line diff |
Lock committed, constraint already permissive (>= 5.0) |
Lock file only — the constraint is untouched |
The third case is the dangerous one. Our svc-a Terraform uses >= 5.0, so the constraint already allows 8.x. Dependabot never touches versions.tf; it silently rewrites the lock to a new major. A reviewer scanning for a version-constraint change sees none, and the diff is 50 lines of zh: hashes that nobody reads.
This also quietly breaks the cross-directory grouping feature described below: the docs note that when directories carry incompatible version constraints for the same dependency, Dependabot falls back to separate PRs. We have ~> 8.8 in five directories, ~> 8.6 in one and >= 5.0 in another, so we are squarely in that case.
4. CI verifies nothing, and it looks green anyway
This is the one that actually matters. A Terraform provider major is exactly the change where you want terraform plan to run — and plan is exactly the job that cannot run on a Dependabot PR.
Workflow runs triggered by Dependabot do not get the id-token: write permission needed for OIDC against a cloud provider. Rather than let the job fail every week, the usual answer is to split it and gate it on the actor:
jobs:
build:
# install deps, lint, test, docker build — no cloud credentials needed
plan:
needs: build
if: github.actor != 'dependabot[bot]'
permissions:
contents: read
id-token: write
That is a reasonable design. The consequence is that gh pr checks on a Terraform Dependabot PR looks like this:
add-to-project skipping
plan skipping
request-reviewer skipping
Everything skipping. And for /terraform/env it is worse — that path matches no workflow's paths: filter at all, so the PR has zero checks. A provider major bump sitting there with nothing red, because nothing ran.
The honest framing: for Terraform, Dependabot is a notification mechanism, not a verification one. Treat a green Terraform PR as "a new version exists," not as "this version works." The verification has to happen when a human runs plan against real state, and no amount of config changes that.
5. One unreachable private module fails the entire update job
Our shared Terraform pulls modules from a private repo in a different GitHub org over an SSH source:
module "something" {
source = "git::ssh://git@github.com/other-org/terraform-modules//path?ref=v1.2.3"
}
Dependabot cannot authenticate to that. The failure mode is not "skip this module and update the rest" — the whole update job for that directory dies with git_dependencies_not_reachable, every week, so the providers in that same directory never get checked either. Since it is a different org and an SSH source, there is no credential you can hand Dependabot to fix it.
The workaround is to exclude it, and the syntax is genuinely hard to guess:
ignore:
- dependency-name: "*::github::other-org/*"
That *::github::owner/* form is the Terraform-specific dependency-name shape. It is worth writing the pattern against the whole org rather than a single repo, so that referencing a second module from the same org later does not resurrect the problem.
Bonus: closing a Dependabot PR is more final than it looks
When you close a Dependabot PR, it replies:
OK, I won't notify you again about this release, but will get in touch when a new version is available.
That is a permanent decision for that specific version. Dependabot also deletes the branch. Once the branch is gone, @dependabot reopen does nothing — the comment is simply ignored. If you close a batch of PRs and then realize you wanted some of them, the ones whose branches are already deleted cannot be recovered through the GitHub API either; you have to reconstruct the updates by hand.
Close with more care than the lightweight-looking UI suggests, especially when bulk-closing.
What we ended up with
- One group per Terraform directory with
patterns: ["*"]and noupdate-typesfilter, sogoogleandgoogle-betaalways move together. - An org-wide
ignorepattern for the unreachable private module source. -
plansplit out ofbuildand gated ongithub.actor != 'dependabot[bot]'. - A written note that Terraform PRs carry no verification signal, so they are reviewed differently from npm and uv ones.
When the dust settled, seven Dependabot PRs were replaced by a single hand-made one touching 16 files across eight directories — which is roughly the honest unit of work for a GCP provider major in a monorepo.
The piece still on the table is consolidation. GitHub shipped cross-directory grouping in February 2026: list paths under directories and set group-by: dependency-name on a group, and you get one PR per dependency across every directory instead of one per directory.
- package-ecosystem: terraform
directories:
- "/terraform/*"
- "/*/terraform"
- "/services/*/terraform"
groups:
gcp-providers:
group-by: dependency-name
patterns:
- "hashicorp/google*"
That is the shape this problem wants. The catch is the constraint-compatibility rule from gotcha 3 — directories with mismatched constraints split back out into separate PRs — so the prerequisite is boring housekeeping: get every directory onto the same constraint first, then let the grouping do its job.
Summary
| Problem | Fix |
|---|---|
| One entry per directory |
directories (plural) with globs |
google / google-beta split across PRs |
Per-directory group, patterns: ["*"], no update-types
|
| Lock-only PRs hide major bumps | Align constraints; read lock diffs, not just .tf diffs |
plan cannot run on Dependabot PRs |
Split build / plan, gate on actor, and stop reading green as "verified" |
| Private module kills the whole job | ignore: dependency-name: "*::github::org/*" |
| Closing a PR is permanent | Bulk-close deliberately; branches are deleted and reopen fails |
None of these are bugs. They all follow from Dependabot treating Terraform providers as ordinary package dependencies, when in practice a provider major is an infrastructure change whose only real test is a plan against live state.
Top comments (0)