Skip to content
Infrastructure as Code iac environments 5 min read

Keeping Environments DRY

The fastest way to ship a second environment is to copy the first one and tweak a few values. The fastest way to accumulate drift, bugs, and 2 a.m. surprises is to do exactly that. DRY — Don’t Repeat Yourself — means describing your infrastructure once and feeding it different inputs per environment, so a fix lands everywhere and the only thing that changes between dev and prod is data, not code. This page shows how to factor shared logic into modules, centralize common values in variables and locals, and lean on tooling to glue it together — all while keeping each environment’s state and blast radius firmly isolated.

Why copy-paste environments rot

When staging/ is a literal copy of prod/, every change is a manual two-step: edit one, remember to edit the other, hope the diff stayed in sync. In practice it never does. A security group gets a port opened in prod but not staging; a retention policy is fixed in one place; the environments silently diverge until “it works in staging” stops meaning anything.

The goal is the opposite: a single source of truth for structure (the resources and how they wire together) and a thin, per-environment layer for configuration (sizes, counts, CIDRs, account IDs). Terraform gives you three tools for this — modules for shared structure, variables and locals for shared and derived values, and wrapper tooling for keeping the per-environment glue small. Everything below is identical under OpenTofu.

Extract shared structure into a module

A module is the unit of reuse. Put the resources that every environment needs into a module and let each environment supply only its inputs. Here is a small networking module that any environment can call:

# modules/network/variables.tf
variable "environment" {
  type        = string
  description = "Environment name used for tagging and naming."
}

variable "vpc_cidr" {
  type        = string
  description = "CIDR block for the VPC."
}

variable "azs" {
  type        = list(string)
  description = "Availability zones to spread subnets across."
}
# modules/network/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "private" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.this.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 4, count.index)
  availability_zone = var.azs[count.index]

  tags = {
    Name        = "${var.environment}-private-${var.azs[count.index]}"
    Environment = var.environment
  }
}

Each environment is now a tiny root module that calls it with environment-specific data:

# environments/prod/main.tf
module "network" {
  source = "../../modules/network"

  environment = "prod"
  vpc_cidr    = "10.0.0.0/16"
  azs         = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

The staging root is the same call with vpc_cidr = "10.1.0.0/16" and two AZs. The resource definitions live in exactly one place.

Tip: Pin module versions when you publish them (source = "git::...//modules/network?ref=v1.4.0"). A registry or Git ref lets you roll staging onto a new module version and promote to prod only after it’s proven — DRY code without lockstep risk.

Centralize common values with variables and locals

Some values are shared by all environments (a company-wide tag set, an org ID, a standard AMI owner). Others are derived from a single per-environment input. Locals are how you compute the second group once instead of repeating expressions across resources.

# environments/prod/locals.tf
locals {
  common_tags = {
    Team       = "platform"
    CostCenter = "cc-4821"
    ManagedBy  = "terraform"
  }

  # Derive everything from one switch, not five.
  name_prefix = "${var.project}-${var.environment}"
  is_prod     = var.environment == "prod"

  instance_type = local.is_prod ? "m6i.large" : "t3.small"
  min_nodes     = local.is_prod ? 3 : 1
}

Resources then consume the merged result, so a new mandatory tag is added in one line and propagates everywhere:

resource "aws_instance" "api" {
  ami           = data.aws_ami.base.id
  instance_type = local.instance_type

  tags = merge(local.common_tags, {
    Name        = "${local.name_prefix}-api"
    Environment = var.environment
  })
}

Per-environment values themselves belong in *.tfvars files, keeping the .tf code free of literals:

# environments/prod/prod.tfvars
project     = "devcraftly"
environment = "prod"
terraform -chdir=environments/prod apply -var-file="prod.tfvars"

Output:

module.network.aws_vpc.this: Creating...
module.network.aws_vpc.this: Creation complete after 2s [id=vpc-0a1b2c3d4e5f60718]
module.network.aws_subnet.private[0]: Creating...
module.network.aws_subnet.private[1]: Creating...
module.network.aws_subnet.private[2]: Creating...

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Choosing a DRY strategy

There is no single right level of factoring — it depends on how much your environments share versus how isolated they must be.

ApproachReuse mechanismBest when
CLI workspacesOne config, state-per-workspaceEnvironments are near-identical and short-lived
Module + per-env rootsShared module, thin roots + tfvarsEnvironments share structure but differ in sizing/CIDRs
TerragruntDRY backend/provider config, includeMany environments, want zero backend boilerplate
Symlinked/generated configsTemplated .tf generationHighly repetitive multi-account/multi-region fleets

The module + per-env root pattern is the sweet spot for most teams: maximum code reuse, while each environment keeps its own directory, its own backend, and its own state — so a botched apply can never reach across the boundary.

Warning: DRY is not the same as shared state. Reuse the code, never the state file. Each environment must have an isolated backend (separate S3 key, separate workspace, or separately an account). Collapsing environments into one state to “save duplication” couples their blast radius and defeats the point.

Keep the glue thin with tooling

Even with modules, the per-environment roots carry repeated backend and provider blocks. Tools like Terragrunt remove that last layer of duplication by generating backend config and letting child directories include a single parent configuration, so each environment is reduced to just its inputs. If you stay in plain Terraform, a small Makefile or shell wrapper that injects the right -chdir and -var-file keeps invocations consistent and prevents the classic “applied prod with staging’s vars” mistake.

Best Practices

  • Define each resource once in a module; let environments differ only through inputs, never through copied resource blocks.
  • Drive per-environment behavior from a single environment variable and compute the rest with locals (is_prod, sizing maps), not scattered conditionals.
  • Keep literals out of .tf files — put per-environment values in *.tfvars and shared tags in a common_tags local merged everywhere.
  • Reuse code but isolate state: a distinct backend per environment so blast radius never crosses boundaries.
  • Version your shared modules and promote a tested version from lower to higher environments rather than editing them in place.
  • Use a wrapper (Terragrunt, Make, or a script) to standardize -chdir/-var-file and eliminate human error in environment selection.
Last updated June 14, 2026
Was this helpful?