Every team I have worked with starts the same way. Someone writes Terraform for dev, it works, and prod gets a copy with a few values changed. A year later there are three near-identical directories, and nobody can say with confidence how they differ. That last part bothers me most: if I cannot say how prod differs from staging, I cannot say what controls prod actually has.
The _envcommon layout, a common Terragrunt pattern, is how I deal with that. It also solves a second problem that matters just as much to me: keeping developers in one repo.
The problem: copies drift
When each environment owns a full copy of its Terraform, a few things happen over time:
- A fix lands in prod during an incident and never gets back to dev.
- Staging stays on an old module version because nobody wants to touch it.
- One environment loses a tag or an encryption setting that the others have.
- Reviewers get 600-line diffs that are mostly noise.
The environments are supposed to be the same system at different sizes. Copies make them different systems that happen to look alike.
Why I use it: developers stay in one repo
Our Terraform modules live in a separate repository. They carry the standards we care about: naming conventions, the guardrails each resource must meet from a security point of view, and the defaults that keep resources consistent. We change those rarely, on purpose, so they stay isolated and stable.
The catch is what that means for developers. If a developer needs a queue or a database, the obvious setup is that they work in two repos: the modules repo to understand what a resource expects, and the live repo to use it. I do not want that. Two repos means two sets of reviews, two sets of versions to keep in your head, and a lot of time spent on rules that are not what they are trying to ship.
_envcommon is how I avoid it:
- The module source, version pin, naming and guardrails sit behind one
_envcommonfile per module. A developer works in the live repo only. - They do not need to know what a resource requires from a security perspective, or which guardrails are mounted on the module. Those are already applied.
- The modules are not hidden. If someone needs to read one, they can. They just never have to.
- A developer's change is a few lines in a leaf file, such as a size, a count or a name, and nothing else.
The goal is simple: developers spend their time on the things that help them ship faster, and the platform side owns the standards.
The layout
The idea is to define each module once, in a shared directory, and let environments say only what is different.
infra/
root.hcl # remote state, provider generation
_envcommon/
vpc.hcl
eks.hcl
rds.hcl
dev/
eu-west-1/
env.hcl # environment = "dev", account id, etc.
vpc/terragrunt.hcl
eks/terragrunt.hcl
rds/terragrunt.hcl
staging/
eu-west-1/
env.hcl
vpc/terragrunt.hcl
eks/terragrunt.hcl
rds/terragrunt.hcl
prod/
eu-west-1/
env.hcl
vpc/terragrunt.hcl
eks/terragrunt.hcl
rds/terragrunt.hcl
Each leaf directory is its own Terragrunt unit with its own state. env.hcl holds facts about the environment, and _envcommon/*.hcl holds everything about a module that does not depend on the environment.
A shared config
Here is _envcommon/eks.hcl. It pins the module source, reads environment facts, declares its dependency on the VPC, and sets the baseline inputs.
locals {
env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
env = local.env_vars.locals.environment
base_source = "git::git@github.com:example-org/terraform-modules.git//eks"
version = "v2.3.0"
}
terraform {
source = "${local.base_source}?ref=${local.version}"
}
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-00000000"
private_subnets = ["subnet-00000000", "subnet-11111111"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
cluster_name = "platform-${local.env}"
cluster_version = "1.30"
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.private_subnets
# Security baseline, applied everywhere
secrets_encryption_enabled = true
endpoint_public_access = false
enabled_log_types = ["api", "audit", "authenticator"]
tags = {
ManagedBy = "terragrunt"
Environment = local.env
DataClass = "internal"
}
}
Encryption, private endpoints, audit logging and tags are written once. No environment can forget them without someone deliberately overriding them in a reviewed diff.
An environment file
Here is prod/eu-west-1/eks/terragrunt.hcl:
include "root" {
path = find_in_parent_folders("root.hcl")
}
include "envcommon" {
path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/eks.hcl"
expose = true
}
locals {
env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}
inputs = merge(
include.envcommon.inputs,
{
node_min_size = 3
node_max_size = 12
node_instance_type = "m6i.xlarge"
tags = merge(
include.envcommon.inputs.tags,
{ CostCenter = local.env_vars.locals.cost_center }
)
}
)
With expose = true, everything in the shared file is reachable as include.envcommon.*, so I can merge the common inputs and add to the tag map instead of replacing it. A plain top-level key in inputs would override the shared value for that key, which is exactly what I want for sizes and counts but not for a tag map. Dev's file is the same shape with node_min_size = 1 and a smaller instance type.
The rds.hcl file follows the same pattern: shared source, a dependency "vpc" block, storage encryption on, backups on, and the environment sets instance_class, multi_az and backup_retention_period.
Where it paid off: queues across 20+ staging environments
The place I felt this most was a setup with more than twenty staging environments. Each one needed the same set of SQS queues, and the same went for Lambdas, SNS topics and DynamoDB tables. The module did not own the queues. They were passed in as an array from each environment's terragrunt.hcl, so every environment carried its own hand-written list.
That caused three problems:
- Adding a queue everyone needed meant editing twenty-plus files and hoping I did not miss one.
- The lists drifted. Staging 7 had a dead-letter queue that staging 12 did not, and nobody noticed until something failed.
- Developers who only wanted one extra queue for their own environment had to understand the whole list first.
I moved the shared list into _envcommon and let each environment add to it.
# _envcommon/sqs.hcl
locals {
common_queues = [
{ name = "orders", visibility_timeout = 60, dlq = true },
{ name = "notifications", visibility_timeout = 30, dlq = true },
{ name = "audit-events", visibility_timeout = 120, dlq = true },
]
}
inputs = {
common_queues = local.common_queues
}
# staging-12/eu-west-1/sqs/terragrunt.hcl
include "envcommon" {
path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/sqs.hcl"
expose = true
}
inputs = {
# Only what this environment needs on top of the common set
extra_queues = [
{ name = "reports-backfill", visibility_timeout = 300, dlq = true },
]
}
The module concatenates common_queues and extra_queues, so the environment never repeats the shared list. The same shape works for Lambdas, SNS topics and DynamoDB tables.
This changed a few things for me:
- A new common queue is one edit in
_envcommon, and it reaches every environment on the next apply. - Every staging environment has the same baseline, so a bug found in one can be reproduced in another.
- A developer who needs a queue adds an entry to
extra_queuesin their own environment. It is a three-line pull request, and they do not touch anyone else's setup. - Looking at an environment's
extra_queuesshows exactly how it differs from the rest.
I would still pick extra_queues over a full override. Letting an environment replace the common list brings back the drift I was trying to remove.
The same problem in production, across 10+ regions
Production had the same problem along a different axis. SQS queues, SNS topics, Lambdas and DynamoDB tables are regional, so running production in more than ten regions meant the same set of resources had to exist in every one of them. With per-region arrays, "add a queue to production" meant ten or more edits, and a missed region only showed up when traffic failed over to it.
The fix was the same, with the region as the unit that includes _envcommon:
prod/
eu-west-1/
region.hcl # aws_region = "eu-west-1"
sqs/terragrunt.hcl
us-east-1/
region.hcl
sqs/terragrunt.hcl
ap-southeast-2/
region.hcl
sqs/terragrunt.hcl
...
Every sqs/terragrunt.hcl includes the same _envcommon/sqs.hcl, so all regions get the common queues. A region that needs something extra, such as a queue for a local data-residency requirement, sets extra_queues and nothing else. Most region files are only the include block.
What I got from this:
- Adding a queue to production is one change in
_envcommon, applied region by region. I roll it out to one region first, watch it, then continue. - A new region is a copy of a small directory, with its
region.hcland a few include-only files. It starts with the same resources as the others. - Region differences are limited to
extra_queuesand sizing, so they are easy to review. - If a region misbehaves, I can trust that its baseline matches the healthy ones.
Production regions carry more risk than staging ones, so the rollout order matters: a change goes through the staging environments first, then one production region, then the rest.
Promoting a change
A change moves through environments as a sequence of small pull requests:
- Change the module, tag it, for example
v2.4.0. - Bump
versionfor that module in dev only. Because the version lives in_envcommon, I make it overridable: the environment file can set its ownterraform { source = ... }block when it needs to run ahead of the others. - Apply to dev, let it soak, check logs and alarms.
- Repeat for staging, then prod, each as its own reviewed change.
When all environments are on the new version, I move the pin into _envcommon and remove the overrides. The overrides are visible in the repo, so "who is ahead of whom" is a grep, not a guess.
What this buys me
| Benefit | Why |
|---|---|
| One repo for developers | Modules and their guardrails live in another repo, but developers only touch this one. |
| DRY | A module's wiring lives in one file, not three or thirty. |
| Consistency | Environments differ only in the few lines they override. |
| Less drift | There is no second copy to forget to update. |
| Smaller reviews | A prod change is a handful of lines, and the diff shows intent. |
| Safer promotion | Versions move one environment at a time. |
| Security baselines | Encryption, tagging and least-privilege settings are defined once. |
| Blast radius | Each environment and module has its own state, so a bad apply in dev EKS cannot touch prod RDS. |
Tradeoffs
This pattern is not free.
- Indirection. To understand one unit you read the leaf file, the
_envcommonfile,env.hclandroot.hcl. New people need a walkthrough. - Wider blast radius for edits. A change in
_envcommon/eks.hcltouches every environment at once. That is why I pin module sources to tags and never to a branch, and why I treat_envcommonchanges as higher risk than leaf changes. - Plan everywhere in CI. Any pull request that touches
_envcommonshould runterragrunt run-all planacross all environments, so the reviewer sees the effect on prod before merging, not after. - Debugging includes. When an input has a surprising value, check with
terragrunt render-jsonorterragrunt run-all planon a single unit to see the final merged config. Merge rules (shallow by default) cause most of the confusion. - Dependencies. Mock outputs are convenient, but limit them to
validateandplan, or you will eventually apply against fake IDs.
Checklist
- One
_envcommonfile per module, with a pinned module version, so developers never need the modules repo. env.hclper environment and region for account and naming facts.- Leaf files only override sizes, counts, CIDRs and versions.
- Security defaults (encryption, logging, tags, private access) live in
_envcommon. - Separate remote state per environment and module.
- CI runs
run-all planon every pull request that touches_envcommon. - Promote dev, then staging, then prod, as separate reviewed changes.
- Mock outputs are allowed for
validateandplanonly.