← All blogs

INFRASTRUCTURE ·

Managing IaC with Terragrunt: The _envcommon Layout

How a shared _envcommon directory in Terragrunt keeps developers in one repo and dev, staging and prod consistent, with tiny per-environment files and one security baseline.

Manoj Paramsetti

Manoj Paramsetti

DevOps Engineer at SurveySparrow

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:

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 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:

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:

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:

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:

  1. Change the module, tag it, for example v2.4.0.
  2. Bump version for that module in dev only. Because the version lives in _envcommon, I make it overridable: the environment file can set its own terraform { source = ... } block when it needs to run ahead of the others.
  3. Apply to dev, let it soak, check logs and alarms.
  4. 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

BenefitWhy
One repo for developersModules and their guardrails live in another repo, but developers only touch this one.
DRYA module's wiring lives in one file, not three or thirty.
ConsistencyEnvironments differ only in the few lines they override.
Less driftThere is no second copy to forget to update.
Smaller reviewsA prod change is a handful of lines, and the diff shows intent.
Safer promotionVersions move one environment at a time.
Security baselinesEncryption, tagging and least-privilege settings are defined once.
Blast radiusEach environment and module has its own state, so a bad apply in dev EKS cannot touch prod RDS.

Tradeoffs

This pattern is not free.

Checklist