All posts
Infrastructure as Code

A Terragrunt Structure That Survives Ten Environments

DevLift Engineering7 min read

You have already decided whether you need Terragrunt. This post is about what comes next, which is the question nobody warns you about: how to lay it out.

At three environments almost any structure works. The duplication is small, you remember where everything is, and a bad decision costs you nothing.

At ten it stops being forgiving. Ten environments across two accounts and two regions is forty state files, and the layout you chose in week one is now the thing determining whether adding an eleventh takes an hour or a day.

The layout everybody starts with

It looks like this, and there is nothing wrong with it yet.

live/
  dev/
    vpc/terragrunt.hcl
    rds/terragrunt.hcl
    ecs/terragrunt.hcl
  staging/
    vpc/terragrunt.hcl
    ...
  prod/
    ...

Environment at the top, one directory per module. Clean, obvious, and it breaks at the first of two moments.

A second region arrives. Now you need prod in eu-west-1 and ap-south-1. Do you add prod-eu and prod-ap at the top level, or push region underneath environment? Whichever you pick, you are renaming state file paths, and renaming a state path means migrating state.

A second account arrives. Production moves to its own AWS account with different credentials. That is not a variable, it is a different assume-role target for every module underneath it, and it has nowhere to live in this tree.

Both problems have the same root cause. The layout encoded one dimension when there were always going to be three.

Which things deserve a directory?

Here is the rule that makes the rest of this easy.

A thing deserves a directory when it changes where the state lives or which credentials are used.

Account, region and environment all qualify. Each one produces a genuinely separate state file that a genuinely separate set of permissions can reach.

Instance size, replica count, whether a feature is on, retention days: none of these qualify. They are inputs. Giving them a directory buys you nothing and costs you a copy of every file underneath.

Applied, that gives you:

live/
  terragrunt.hcl              # root: shared by everything below
  prod/
    account.hcl               # account id, assume-role
    eu-west-1/
      region.hcl              # region name, AZs
      env.hcl                 # sizing and flags for this env
      vpc/terragrunt.hcl
      rds/terragrunt.hcl
      ecs/terragrunt.hcl
    ap-south-1/
      region.hcl
      env.hcl
      ...
  staging/
    account.hcl
    eu-west-1/
      ...

Three levels, three real dimensions, and every leaf is a unit with its own state.

The reason this holds up is that path_relative_to_include() now derives a state key that already contains all three. You never write a state path by hand, and adding a region does not disturb any existing one.

What goes in the root configuration?

Only what is identical everywhere.

# live/terragrunt.hcl

locals {
  account = read_terragrunt_config(find_in_parent_folders("account.hcl"))
  region  = read_terragrunt_config(find_in_parent_folders("region.hcl"))
}

remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket         = "acme-tfstate-${local.account.locals.account_id}"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "eu-west-1"
    encrypt        = true
    dynamodb_table = "acme-tf-locks"
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "aws" {
  region = "${local.region.locals.aws_region}"
  assume_role { role_arn = "${local.account.locals.role_arn}" }
  default_tags { tags = { ManagedBy = "terragrunt" } }
}
EOF
}

The state bucket is per account, the key is derived from position in the tree, and the provider reads its region and role from the two config files above it. Nothing in there names an environment.

The rule for the root file: the first conditional you write in it is a signal. The moment you find yourself typing something that means "unless this is production", that thing is not identical everywhere and belongs in env.hcl instead. Root files that accumulate conditionals become the single file that every change touches and every merge conflicts on.

How big should one unit be?

One state file per thing that changes at a different rate.

That phrasing does more work than "one per module" or "keep them small". Your VPC changes twice a year. Your ECS service changes twice a week. Putting them in the same state means every routine deploy holds a lock on your networking and every plan reads the whole thing.

Practically, splitting along those lines gives you something like: networking, data stores, shared platform services, then one unit per application. Between roughly six and fifteen units per environment is the range where this feels right. Below that, plans are slow and blast radius is wide. Far above it, you are managing dependency wiring more than infrastructure.

Where do the environment differences live?

In exactly one file per environment, read as data.

# live/prod/eu-west-1/env.hcl
locals {
  environment    = "prod"
  db_instance    = "db.r6g.xlarge"
  db_multi_az    = true
  min_capacity   = 4
  retention_days = 90
}
# live/prod/eu-west-1/rds/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

locals {
  env = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}

terraform {
  source = "git::git@github.com:acme/tf-modules.git//rds?ref=v2.4.0"
}

dependency "vpc" {
  config_path = "../vpc"
  mock_outputs = {
    private_subnet_ids = ["subnet-mock"]
  }
}

inputs = {
  environment       = local.env.locals.environment
  instance_class    = local.env.locals.db_instance
  multi_az          = local.env.locals.db_multi_az
  backup_retention  = local.env.locals.retention_days
  subnet_ids        = dependency.vpc.outputs.private_subnet_ids
}

Two things this buys you.

Every difference between staging and production is visible in one diff. Someone asking why production costs four times more can read two files rather than grep forty.

The leaf files become boilerplate. The rds/terragrunt.hcl in staging is the same file as the one in production. That is a feature. Boilerplate that is genuinely identical is not duplication you need to solve, it is the thing that makes copying an environment safe.

Pinning the module version per environment

Notice the ref=v2.4.0 in that source. Put it in env.hcl and you get something valuable: environments can sit on different module versions on purpose.

Staging runs v2.5.0, production runs v2.4.0 until staging has been on the new one for a week. That is a release process, expressed in one line per environment, and it is very hard to retrofit if every leaf hardcodes its own version string.

Making run-all safe

run-all is the reason to have Terragrunt at forty units, and it is also the command most likely to ruin a Tuesday.

Three habits that help:

Never run it above an environment. Scope it to one env directory. A run-all apply from the repository root is a command that can touch production because you were in the wrong shell.

Mock every dependency output you consume. Without mocks, run-all plan fails on a fresh branch where the upstream unit does not exist yet, which is exactly when you most want a plan.

Read the unit list before applying. run-all plan prints which units it is about to touch. That list is the actual safety check, and it takes four seconds to read.

The eleventh environment test

The real measure of a layout is not tidiness. It is this: what do you touch to add another environment?

With the structure above, adding staging-2 in a new region means creating a directory, writing a region.hcl and an env.hcl, and copying the leaf files that are already identical everywhere.

The number that matters is how many existing files you edit. It should be zero. No case statement in the root, no new entry in a list of environments, no state paths renamed. If adding an environment forces you to modify something that already works, the layout has a dimension encoded in the wrong place, and it will keep costing you every time.

What still bites at ten

Three things this layout does not fix, so you know they are coming.

Wall clock time. Forty units means run-all plan takes real minutes. Parallelism helps, and past a point you are splitting CI jobs per environment anyway.

Silent divergence. Environments that are supposed to be identical will not stay identical, because someone will fix something in production at 2am and never backport it. Terragrunt does not detect that. Drift detection is a separate problem and you will need an answer for it.

Provider version skew. Ten environments applied at different times means ten lock files that slowly disagree. Pin the provider version in the root generate block and update it deliberately rather than letting each environment pick up whatever was current on the day it last ran.

The short version

Directories for the three things that change state location or credentials. Variables for everything else. One file per environment holding every difference. Root configuration that contains no conditionals.

Then check it against the eleventh environment test. If the answer is zero existing files edited, the structure will hold. If it is not, fix it now, while the migration is ten state files rather than forty.


DevLift generates Terragrunt from your existing structure, with backend keys, provider blocks and dependency wiring derived rather than copied, and every change arriving as a pull request so your review still runs. Book a walkthrough, or read whether you need Terragrunt at all.

See what this looks like on your own cloud account

DevLift's agents run continuous cost, drift and compliance detection across AWS, Azure and GCP, and propose fixes as reviewable changes, not dashboards. A walkthrough takes 30 minutes.

Schedule a demo

Keep reading