Skip to content

Structuring Terraform for multiple environments without copy-paste

Modules for logic, thin roots per environment and layer, tfvars for differences, and state isolated per account. The Terraform layout we use for two to ten environments.

Written by

David Lee

Enterprise Solutions Architect

Published
Reading time
7 min
Share
Image: Amsterdam City Archives on Unsplash · Source
On this page
  1. What we are optimising for
  2. The options
  3. The layout
  4. A live root
  5. Backends and state isolation
  6. Passing values between layers
  7. Module versioning
  8. CI
  9. When to reach for something else
  10. What we'd do differently
  11. Checklist

Almost every Terraform codebase we inherit has the same history. It started with one environment. Then staging was needed, so someone copied the folder. Then a second region, so another copy. Two years later, there are four near-identical directories, each with small differences nobody can explain, and a change to the database module has to be made in four places and reviewed four times.

This post describes the structure we now use for clients with between two and ten environments on AWS. It keeps each environment's differences in one small file, isolates state so a mistake in development cannot touch production, and works with plain Terraform (or OpenTofu) without extra wrappers. We also cover when workspaces, Terragrunt or Stacks are the better choice.

What we are optimising for

Any multi-environment structure is a trade-off. These are the properties we care about, in rough order:

  1. Small blast radius. A terraform apply in staging must not be able to change production. A change to DNS must not require planning the whole database layer.
  2. One definition of each component. The VPC, the cluster and the database are defined once. Environments differ only in inputs.
  3. Differences are visible. Someone reviewing a pull request should see exactly how production differs from staging, in one place.
  4. Plain tooling. A new engineer should be able to run terraform plan with nothing but Terraform and cloud credentials.

The options

ApproachSeparate state per envEnv differences visibleExtra toolingOur view
Copy-pasted directoriesYesNo, buried in diffsNoneDrifts within months
Terraform CLI workspacesYes, same backendPartly, via conditionalsNoneFine for identical, short-lived envs
Modules plus thin root per envYesYes, in tfvarsNoneOur default
TerragruntYesYesTerragruntGood at large scale
HCP Terraform StacksYesYesHCP TerraformWorth watching; depends on platform choice

CLI workspaces look attractive because they reuse one directory. The problems appear later: all environments share one backend configuration and usually one set of credentials, and environment differences end up as terraform.workspace == "prod" ? ... : ... conditionals scattered through the code. It is also easy to run apply in the wrong workspace. We use workspaces for ephemeral preview environments that are identical by design, not for staging versus production.

The layout

text
infra/
  modules/
    network/
    eks-cluster/
    postgres/
    service-iam/
  live/
    staging/
      network/
        main.tf
        backend.hcl
        terraform.tfvars
      platform/
        main.tf
        backend.hcl
        terraform.tfvars
    production/
      network/
      platform/
  shared/
    providers.tf
    versions.tf

There are two ideas here.

Modules hold all the logic. modules/ contains reusable, versioned components with inputs and outputs. They never reference an environment by name.

Live roots are thin. Each directory under live/ is a Terraform root module with its own state. Its main.tf is mostly module calls wired together; its terraform.tfvars holds the values that differ. The production and staging main.tf files are identical, and we check that in CI.

Roots are split by layer as well as environment: network (VPC, subnets, endpoints, DNS zones), platform (EKS, databases, caches) and, for some clients, an apps layer. Layers change at different rates and carry different risk. Nobody should have to plan the VPC to change a Redis parameter.

A live root

Here is the platform root. The same file lives in every environment.

hcl
# live/production/platform/main.tf
terraform {
  backend "s3" {}
}

provider "aws" {
  region = var.region
  assume_role {
    role_arn = var.deploy_role_arn
  }
  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "terraform"
      Layer       = "platform"
    }
  }
}

data "aws_ssm_parameter" "vpc_id" {
  name = "/${var.environment}/network/vpc_id"
}

data "aws_ssm_parameter" "private_subnets" {
  name = "/${var.environment}/network/private_subnet_ids"
}

module "cluster" {
  source = "../../../modules/eks-cluster"

  name               = "${var.environment}-main"
  kubernetes_version = var.kubernetes_version
  vpc_id             = data.aws_ssm_parameter.vpc_id.value
  subnet_ids         = split(",", data.aws_ssm_parameter.private_subnets.value)
}

module "postgres" {
  source = "../../../modules/postgres"

  identifier        = "${var.environment}-orders"
  instance_class    = var.db_instance_class
  multi_az          = var.db_multi_az
  allocated_storage = var.db_storage_gb
  subnet_ids        = split(",", data.aws_ssm_parameter.private_subnets.value)
}

And the file that carries the difference:

hcl
# live/production/platform/terraform.tfvars
environment        = "production"
region             = "eu-west-1"
deploy_role_arn    = "arn:aws:iam::111111111111:role/terraform-deploy"
kubernetes_version = "1.32"
db_instance_class  = "db.r6g.2xlarge"
db_multi_az        = true
db_storage_gb      = 500

Staging's tfvars is the same shape with smaller numbers, a different account ID and db_multi_az = false. A reviewer comparing environments diffs two short files.

Backends and state isolation

We use one AWS account per environment, and each account holds its own state bucket. That gives the strongest isolation: the credentials that can apply staging cannot read or write production state at all.

The backend block in main.tf is intentionally empty. Values come from a partial configuration file passed at init time:

hcl
# live/production/platform/backend.hcl
bucket       = "example-tfstate-production"
key          = "platform/terraform.tfstate"
region       = "eu-west-1"
encrypt      = true
use_lockfile = true
bash
cd infra/live/production/platform
terraform init -backend-config=backend.hcl
terraform plan -out=tfplan

Since Terraform 1.10, the S3 backend supports native state locking with use_lockfile = true, which writes a lock object next to the state file using S3 conditional writes. You no longer need a DynamoDB table for locking, and DynamoDB-based locking is deprecated. If you are on OpenTofu, 1.8 and later also allow variables and locals in backend configuration through early evaluation, which can remove the separate backend.hcl altogether.

(The bucket name above is a placeholder. Bucket names are global, so include something unique to your organisation.)

Passing values between layers

The platform layer needs the VPC ID from the network layer. There are two common ways to do this.

terraform_remote_state reads the other layer's state file directly. It works, but it gives the consumer read access to the whole state, including any secrets in it, and it couples the two layers to each other's output names.

We prefer publishing a small, explicit contract. The network layer writes the values other layers need to SSM Parameter Store, and consumers read them with aws_ssm_parameter data sources, as in the example above.

hcl
# live/production/network/outputs.tf
resource "aws_ssm_parameter" "vpc_id" {
  name  = "/${var.environment}/network/vpc_id"
  type  = "String"
  value = module.network.vpc_id
}

resource "aws_ssm_parameter" "private_subnet_ids" {
  name  = "/${var.environment}/network/private_subnet_ids"
  type  = "StringList"
  value = join(",", module.network.private_subnet_ids)
}

This also means non-Terraform consumers, such as deployment scripts or Helm values, can read the same parameters.

Module versioning

For a single team, referencing modules by relative path is fine. Every environment picks up changes on the next apply, and the promotion path is simply "apply staging, then production".

Once several teams share modules, pin versions. We tag the modules repository and reference tags from the live roots:

hcl
module "postgres" {
  source = "git::https://github.com/example-org/terraform-modules.git//postgres?ref=postgres-v2.4.0"
  # ...
}

Promotion then becomes explicit: bump the ref in staging, apply, verify, bump it in production. The pull request that changes production shows exactly which module version it moves to.

CI

Every pull request runs terraform fmt -check, terraform validate and a plan for every root it touches. We detect touched roots with a path filter on live/** and modules/**; a module change triggers plans in every root that uses it. Plans are posted to the pull request as a comment.

Applies run on merge, staging first. Production applies require a manual approval in the CI system, and use a deploy role that only the production pipeline can assume. Engineers do not have standing write access to production state.

We also run a scheduled nightly plan against every root. Any non-empty plan means something changed outside Terraform, and it opens a ticket.

When to reach for something else

Terragrunt is worth it when you have dozens of roots, many accounts and regions, and the repetition in main.tf and backend files becomes the problem. It generates backend configuration, handles dependencies between roots, and can run plans across many of them at once. The cost is another tool every engineer must learn.

HCP Terraform Stacks model multiple deployments of the same configuration natively. If you are already on HCP Terraform, it is worth evaluating. If you are not, adopting a platform to get one feature is a bigger decision than this post can settle.

What we'd do differently

  • Split state by layer from the start. Splitting a large state later means a series of terraform state mv operations or moved and import blocks, and it is slow, careful work.
  • Use one account per environment from day one. Retrofitting account boundaries touches IAM, networking and DNS at once.
  • Add the "live roots must be identical" CI check early. Without it, someone will eventually add a production-only resource directly to production's main.tf, and the structure starts to erode.
  • Keep secrets out of state where possible, generating them in Secrets Manager and passing references instead of values.

Checklist

  1. All logic lives in modules that never mention an environment by name.
  2. Each environment and layer is its own root with its own state.
  3. Environment differences live only in terraform.tfvars.
  4. State lives in a per-account bucket with encryption and use_lockfile = true.
  5. Layers share values through SSM parameters, not by reading each other's state.
  6. Shared modules are version-pinned and promoted explicitly.
  7. CI runs fmt, validate and plan on every touched root, and posts the plan.
  8. Production applies need approval and a dedicated role.
  9. A nightly plan catches drift.

None of this requires anything beyond Terraform itself. It is mostly the discipline of keeping one definition per component and pushing every difference into a file small enough to read in a code review.

More in Cloud

Cloud

AWS Cost Optimization: Reduce Spending by 40%

Discover proven techniques to reduce your AWS spending by 40% without sacrificing performance. Real-world strategies from organizations scaling to millions of users.

Sarah Chen

14 min read

Cloud

Infrastructure as Code: Terraform Best Practices

Master Infrastructure as Code with Terraform. Learn state management, modularity, testing strategies, and collaboration practices for enterprise-scale deployments.

Sarah Chen

12 min read

Need help putting this into practice?

Talk to the engineers who write these posts. A 30-minute call gets you a rough plan, a timeline and a price range for your setup, whether or not we work together.

  • Reply within one business day
  • NDA on request
  • Senior engineer on every call