Eliminating Terraform Duplication with Terramate Globals and Code Generation
Terramate's hierarchical globals and code generation eliminate Terraform boilerplate duplication across monorepo stacks. Define backend, provider, and version config once at the root, override per environment or region, and generate consistent HCL files in every stack directory.
09 Apr 2026, 13:01 UTC

The duplication problem in Terraform monorepos
Teams managing dozens of Terraform stacks in a single repository quickly hit a wall: every stack needs a backend.tf, provider.tf, and versions.tf. Copy-pasting these files across env/dev/us-east-1, env/staging/eu-west-1, env/prod/ap-southeast-2 creates drift risk — a backend bucket name change means editing 30 files. Wrapper scripts help but add another maintenance burden.
Terramate solves this by treating configuration as data. You define globals once at the repository root, let them cascade down the directory hierarchy, and generate the required Terraform files automatically. Child stacks can override any value without touching the generated output.
How globals cascade and generate files
A terramate.tm.hcl at the repo root declares globals — maps, lists, strings, and functions like terraform_include, git_root, dir_name. Each child stack inherits these globals and can override them in its own terramate.tm.hcl. The generate_hcl block uses Go templates to write backend.tf, provider.tf, versions.tf (or any filename) into each stack directory.
Evaluation order is root-to-leaf. A child override replaces the entire map value; deep merging requires explicit helper globals. Generated files are written to disk and committed, so reviewers see the exact HCL Terraform will execute.
Worked example: multi-environment, multi-region stacks
Repository layout:
infra/
├── terramate.tm.hcl # root globals
├── env/
│ ├── dev/
│ │ ├── us-east-1/
│ │ │ └── terramate.tm.hcl
│ │ └── eu-west-1/
│ │ └── terramate.tm.hcl
│ ├── staging/
│ │ └── ...
│ └── prod/
│ └── ...
Root globals (infra/terramate.tm.hcl)
globals {
backend_bucket = "my-org-terraform-state"
backend_dynamodb_table = "terraform-locks"
provider_regions = {
us-east-1 = "us-east-1"
eu-west-1 = "eu-west-1"
ap-southeast-2 = "ap-southeast-2"
}
terraform_version = "~> 1.9"
required_providers = {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
generate_hcl "backend.tf" {
content = templatefile("${git_root()}/templates/backend.tmpl", {
bucket = globals.backend_bucket
key = "${dir_name()}/terraform.tfstate"
dynamodb_table = globals.backend_dynamodb_table
region = globals.provider_regions[dir_name()] // assumes stack dir matches region
})
}
generate_hcl "provider.tf" {
content = templatefile("${git_root()}/templates/provider.tmpl", {
region = globals.provider_regions[dir_name()]
required_providers = globals.required_providers
terraform_version = globals.terraform_version
})
}
Template templates/backend.tmpl
terraform {
backend "s3" {
bucket = "{{ .bucket }}"
key = "{{ .key }}"
dynamodb_table = "{{ .dynamodb_table }}"
region = "{{ .region }}"
encrypt = true
}
}
Template templates/provider.tmpl
terraform {
required_version = "{{ .terraform_version }}"
required_providers {
aws = {
source = "{{ .required_providers.aws.source }}"
version = "{{ .required_providers.aws.version }}"
}
}
}
provider "aws" {
region = "{{ .region }}"
default_tags {
tags = {
managed_by = "terramate"
stack = "{{ dir_name }}"
}
}
}
Child override (infra/env/dev/us-east-1/terramate.tm.hcl)
globals {
# override only the bucket for dev
backend_bucket = "my-org-terraform-state-dev"
}
metadata {
id = "dev-us-east-1"
name = "Development US East 1"
description = "Dev workloads in us-east-1"
tags = ["env:dev", "region:us-east-1"]
}
Run terramate generate at the repo root. Each stack directory now contains backend.tf and provider.tf with the correct bucket, key, region, and provider versions. The dev stack uses the dev bucket; staging and prod inherit the root bucket.
Trade-offs and limitations
- Generated files in git: reviewers must inspect the rendered HCL. A template logic error (e.g., wrong map key) produces valid but wrong Terraform — no runtime guard.
- Map replacement, not deep merge: overriding
required_providersin a child replaces the entire map. To add a provider, redefine the full map or use an intermediate global. - Change detection depends on git history:
terramate list --changedcompares fingerprints against the default branch. Shallow clones, rebased branches, or missing fingerprint cache cause false positives/negatives. - Template complexity: non-trivial Go templates become hard to debug. Extract logic into
.tmplfiles and validate withterramate generate --dry-runbefore committing. - Version pinning: Terramate upgrades can change generation behavior. Pin the version in CI (
terramate version 0.9.2) and test upgrades in a staging repo first.
Actionable next steps
- Create a test repository with the layout above. Run
terramate generateand inspect the generatedbackend.tfin each stack. - Modify a root global (e.g.,
terraform_version) and runterramate list --changed— only descendant stacks should appear. - Add a
depends_onmetadata block to a stack that must run after another, then runterramate run terraform planand verify execution order. - Check the Terramate changelog (github.com/minectl/terramate/releases) for version-specific changes before upgrading.
Start small: generate only backend.tf and provider.tf for a few stacks. Expand once the pattern feels solid. The payoff is a single source of truth for backend, provider, and version configuration across your entire Terraform estate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.