Skip to content

InsightsInfrastructure as Code

Structuring Terraform for many environments

How to lay out modules, state and environment definitions so that adding an environment is a small, reviewable change rather than a copy-paste exercise.

Published October 13, 20263 min read

Most Terraform codebases start the same way: one directory, one state file, one environment. The trouble starts with the second environment. Copying the directory works for a week. After that, every change has to be made twice, the copies drift, and nobody is sure which differences are intentional.

This article describes a layout that scales from two environments to dozens without turning every change into a migration.

Separate what changes from how it is built

The core idea is to split infrastructure code into two kinds of directories:

  • Modules describe how something is built: a network, a cluster, a database. They take inputs and have no knowledge of environments.
  • Live configurations describe what exists: which modules are used in which environment, with which inputs. They contain almost no logic.
live/productionlive/stagingmodules (versioned)providers + backend
fig. 1 — modules are versioned building blocks; live configurations compose them per environment

A typical layout looks like this:

repository layout
infrastructure/
├── modules/
│   ├── network/
│   ├── cluster/
│   └── database/
└── live/
    ├── staging/
    │   ├── network/
    │   ├── cluster/
    │   └── database/
    └── production/
        ├── network/
        ├── cluster/
        └── database/

Draw state boundaries deliberately

One state file per environment is better than one state file overall, but still too coarse. A plan that touches the network, the cluster and every database at once is slow to compute and hard to review.

Split state by rate of change and blast radius:

LayerChangesBlast radiusTypical owner
NetworkRarelyEverythingPlatform team
ClusterMonthlyAll workloadsPlatform team
DatabasesOccasionallyOne serviceService team
Service resourcesOftenOne serviceService team

Each live directory then has its own backend configuration and its own state:

live/production/database/backend.tf
terraform {
  backend "s3" {
    bucket       = "platform-terraform-state"
    key          = "production/database/terraform.tfstate"
    region       = "eu-west-1"
    use_lockfile = true
    encrypt      = true
  }
}

With use_lockfile, Terraform 1.11 and later keep the state lock in the bucket itself. Older setups use a DynamoDB table for the same purpose.

Pass outputs, not assumptions

When layers are split, they need to share values: the database needs the network's subnet IDs. Avoid hard-coding them. Read them from the producing layer's outputs, or better, from a lookup that does not depend on another state file:

live/production/database/main.tf
data "aws_subnets" "private" {
  filter {
    name   = "tag:Tier"
    values = ["private"]
  }
}
 
module "database" {
  source     = "git::ssh://git@git.internal/platform/terraform-modules.git//database?ref=v1.8.0"
  subnet_ids = data.aws_subnets.private.ids
  instance   = "db.r6g.large"
}

Make the differences between environments explicit

Environments should differ only in a small, visible set of inputs: sizes, counts, feature switches. If production needs something staging does not have, that should be an input to the module, not a fork of it.

A useful test: if you diff two environment directories, every line of the output should be a deliberate decision.

Why not workspaces?

Terraform CLI workspaces give one configuration several state files, and they look like a shortcut to environments. They work well for short-lived copies that really are identical, such as a stack per feature branch.

For long-lived environments they hide the differences. Production and staging share one backend, one set of credentials and one directory. Every difference becomes a conditional on terraform.workspace, and a reviewer cannot tell from the diff which environment a change will touch. Separate live directories make that visible.

Automate the workflow around it

The layout pays off when plans and applies run in CI:

commitplanreviewapplydrift check
fig. 2 — every change follows the same reviewed path
  1. A pull request triggers plan for each affected directory.
  2. The plan output is posted to the pull request for review.
  3. Merging applies the reviewed plan.
  4. A scheduled job runs plan against every directory and reports drift.

Summary

  • Keep modules environment-agnostic and versioned.
  • Split state by rate of change and blast radius.
  • Share values through outputs or lookups, never copies.
  • Make environment differences small, explicit inputs.
  • Run everything through a reviewed, automated workflow.