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 rollstagingonto a new module version and promote toprodonly 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.
| Approach | Reuse mechanism | Best when |
|---|---|---|
| CLI workspaces | One config, state-per-workspace | Environments are near-identical and short-lived |
| Module + per-env roots | Shared module, thin roots + tfvars | Environments share structure but differ in sizing/CIDRs |
| Terragrunt | DRY backend/provider config, include | Many environments, want zero backend boilerplate |
| Symlinked/generated configs | Templated .tf generation | Highly 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
environmentvariable and compute the rest withlocals(is_prod, sizing maps), not scattered conditionals. - Keep literals out of
.tffiles — put per-environment values in*.tfvarsand shared tags in acommon_tagslocal 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-fileand eliminate human error in environment selection.